Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@moonlight-protocol/council-platform",
"version": "0.4.24",
"version": "0.5.0",
"license": "MIT",
"exports": "./src/main.ts",
"tasks": {
Expand Down
60 changes: 60 additions & 0 deletions src/core/service/event-watcher/event-watcher.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,66 @@ Deno.test("fetchChannelAuthEvents - parses multiple events in order", async () =
assertEquals(events[2].address, TEST_ADDR_1);
});

function buildChannelStateEvent(
channel: string,
asset: string,
enabled: boolean,
ledger: number,
) {
return {
type: "contract" as const,
ledger,
topic: [
xdr.ScVal.scvSymbol("channel_state_changed"),
new Address(channel).toScVal(),
new Address(asset).toScVal(),
],
value: xdr.ScVal.scvBool(enabled),
id: `${ledger}-0`,
pagingToken: `${ledger}-0`,
inSuccessfulContractCall: true,
contractId: TEST_CONTRACT,
};
}

Deno.test("fetchChannelAuthEvents - parses channel_state_changed (disabled) event", async () => {
const mockServer = createMockServer([
buildChannelStateEvent(TEST_ADDR_1, TEST_ADDR_2, false, 3000),
]);

const { events } = await fetchChannelAuthEvents(
mockServer,
TEST_CONTRACT,
2900,
{ log: newNoop() },
);

assertEquals(events.length, 1);
assertEquals(events[0].type, "channel_state_changed");
assertEquals(events[0].channel, TEST_ADDR_1);
assertEquals(events[0].address, TEST_ADDR_1);
assertEquals(events[0].asset, TEST_ADDR_2);
assertEquals(events[0].enabled, false);
assertEquals(events[0].ledger, 3000);
});

Deno.test("fetchChannelAuthEvents - parses channel_state_changed (enabled) event", async () => {
const mockServer = createMockServer([
buildChannelStateEvent(TEST_ADDR_1, TEST_ADDR_2, true, 3100),
]);

const { events } = await fetchChannelAuthEvents(
mockServer,
TEST_CONTRACT,
3000,
{ log: newNoop() },
);

assertEquals(events.length, 1);
assertEquals(events[0].type, "channel_state_changed");
assertEquals(events[0].enabled, true);
});

Deno.test("fetchChannelAuthEvents - skips events with insufficient topics", async () => {
const mockServer = {
// deno-lint-ignore require-await -- mock satisfies Server.getEvents async contract
Expand Down
24 changes: 23 additions & 1 deletion src/core/service/event-watcher/event-watcher.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Server } from "stellar-sdk/rpc";
import { Address, type xdr } from "stellar-sdk";
import { Address, scValToNative, type xdr } from "stellar-sdk";
import { withSpan } from "@/core/tracing.ts";
import type {
ChannelAuthEvent,
Expand All @@ -11,6 +11,7 @@ const KNOWN_TOPICS: Record<string, ChannelAuthEventType> = {
contract_initialized: "contract_initialized",
provider_added: "provider_added",
provider_removed: "provider_removed",
channel_state_changed: "channel_state_changed",
};

function decodeAddress(val: xdr.ScVal): string {
Expand Down Expand Up @@ -65,6 +66,27 @@ export function fetchChannelAuthEvents(
if (!topicSymbol || !(topicSymbol in KNOWN_TOPICS)) continue;

const eventType = KNOWN_TOPICS[topicSymbol];

// ChannelStateChanged carries two address topics (channel, asset) and a
// boolean data value (enabled). `address` mirrors the channel so the
// common {type, address, ledger} handler shape still applies.
if (eventType === "channel_state_changed") {
if (topics.length < 3) continue;
const channel = decodeAddress(topics[1]);
const asset = decodeAddress(topics[2]);
const enabled = Boolean(scValToNative(rawEvent.value));
parsed.push({
type: eventType,
address: channel,
channel,
asset,
enabled,
ledger: rawEvent.ledger,
contractId,
});
continue;
}

const address = decodeAddress(topics[1]);

parsed.push({
Expand Down
15 changes: 12 additions & 3 deletions src/core/service/event-watcher/event-watcher.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@
* - `contract_initialized` — emitted when the contract is deployed
* - `provider_added` — emitted when a provider is registered
* - `provider_removed` — emitted when a provider is deregistered
* - `channel_state_changed` — emitted when an asset channel is enabled/disabled
*/
export type ChannelAuthEventType =
| "contract_initialized"
| "provider_added"
| "provider_removed";
| "provider_removed"
| "channel_state_changed";

export interface ChannelAuthEvent {
type: ChannelAuthEventType;
/** The address in the event topic (provider or admin) */
/** The address in the event topic (provider or admin); for
* channel_state_changed this is the channel (privacy-channel) contract id. */
address: string;
/** Ledger sequence where the event was emitted */
ledger: number;
/** Contract ID that emitted the event */
/** Contract ID that emitted the event (the channel-auth / council contract) */
contractId: string;
/** channel_state_changed only: the privacy-channel contract id. */
channel?: string;
/** channel_state_changed only: the asset (token) contract id. */
asset?: string;
/** channel_state_changed only: true=enabled/re-enabled, false=disabled. */
enabled?: boolean;
}

export interface EventWatcherConfig {
Expand Down
34 changes: 33 additions & 1 deletion src/core/service/event-watcher/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ import type { Logger } from "@/utils/logger/index.ts";
import { drizzleClient } from "@/persistence/drizzle/config.ts";
import { CouncilMetadataRepository } from "@/persistence/drizzle/repository/council-metadata.repository.ts";
import { CouncilProviderRepository } from "@/persistence/drizzle/repository/council-provider.repository.ts";
import { CouncilChannelRepository } from "@/persistence/drizzle/repository/council-channel.repository.ts";
import { ProviderStatus } from "@/persistence/drizzle/entity/council-provider.entity.ts";
import { ChannelStatus } from "@/persistence/drizzle/entity/council-channel.entity.ts";
import type { ChannelAuthEvent } from "./event-watcher.types.ts";

// Wire env CHALLENGE_TTL (seconds) to council auth (ms)
setChallengeTtlMs(CHALLENGE_TTL * 1000);

const metadataRepo = new CouncilMetadataRepository(drizzleClient);
const providerRepo = new CouncilProviderRepository(drizzleClient);
const channelRepo = new CouncilChannelRepository(drizzleClient);

/**
* Multi-council event watcher.
Expand All @@ -37,7 +41,7 @@ const activeWatchers = new Map<string, EventWatcher>(); // councilId → watcher
let dbSyncTimer: number | null = null;

function makeHandler(councilId: string, log: Logger) {
return async (event: { type: string; address: string; ledger: number }) => {
return async (event: ChannelAuthEvent) => {
switch (event.type) {
case "provider_added": {
log.debug("councilId", councilId);
Expand Down Expand Up @@ -93,6 +97,34 @@ function makeHandler(councilId: string, log: Logger) {
break;
}

case "channel_state_changed": {
// SOLE authoritative writer of channel status. The DB only ever reflects
// CONFIRMED on-chain state — never written ahead of the chain. Any
// optimistic pendingAction marker is cleared here on confirmation.
const channel = event.channel ?? event.address;
const status = event.enabled
? ChannelStatus.ENABLED
: ChannelStatus.DISABLED;
log.debug("councilId", councilId);
log.debug("channel", channel);
log.debug("asset", event.asset ?? "");
log.debug("ledger", event.ledger);
log.debug("status", status);
log.event("channel state changed on-chain");

const updated = await channelRepo.setStatusByContractId(
councilId,
channel,
status,
);
if (updated) {
log.event("channel status reconciled from chain");
} else {
log.event("channel state event for unknown channel (ignored)");
}
break;
}

case "contract_initialized": {
log.debug("councilId", councilId);
log.debug("address", event.address);
Expand Down
53 changes: 39 additions & 14 deletions src/http/v1/council/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { KnownAssetRepository } from "@/persistence/drizzle/repository/known-ass
import { queryChannelState } from "@/core/service/channel/channel-state.service.ts";
import { requireCouncilId, requireCouncilOwnership } from "./helpers.ts";
import { CouncilMetadataRepository } from "@/persistence/drizzle/repository/council-metadata.repository.ts";
import {
ChannelPendingAction,
ChannelStatus,
} from "@/persistence/drizzle/entity/council-channel.entity.ts";
import type { Logger } from "@/utils/logger/index.ts";

const metadataRepo = new CouncilMetadataRepository(drizzleClient);
Expand All @@ -20,6 +24,8 @@ function formatChannel(
assetCode: string;
assetContractId: string | null;
label: string | null;
status: string;
pendingAction: string | null;
totalDeposited: bigint | null;
totalWithdrawn: bigint | null;
utxoCount: bigint | null;
Expand All @@ -32,6 +38,9 @@ function formatChannel(
assetCode: ch.assetCode,
assetContractId: ch.assetContractId,
label: ch.label,
// Confirmed on-chain lifecycle status + optimistic pending marker.
status: ch.status,
pendingAction: ch.pendingAction,
state: {
totalDeposited: ch.totalDeposited?.toString() ?? null,
totalWithdrawn: ch.totalWithdrawn?.toString() ?? null,
Expand Down Expand Up @@ -294,18 +303,28 @@ export function handleRemoveChannel(
return;
}

await channelRepo.update(id, { deletedAt: new Date() });
// Record the council's intent ONLY. The authoritative `status` flip to
// "disabled" is written by the event-watcher when the quorum-authorized
// disable_channel call is confirmed on-chain — never here (no optimistic
// authoritative write). The on-chain quorum tx is signed client-side.
const updated = await channelRepo.setPendingAction(
id,
ChannelPendingAction.DISABLE,
);

log.debug("id", id);
log.debug("channelContractId", channel.channelContractId);
log.event("channel disabled");
log.event("channel disable requested (pending on-chain confirmation)");

ctx.response.status = Status.OK;
ctx.response.body = { message: "Channel disabled" };
ctx.response.status = Status.Accepted;
ctx.response.body = {
message: "Channel disable requested; pending on-chain confirmation",
data: formatChannel(updated),
};
} catch (error) {
log.error(error, "failed to disable channel");
log.error(error, "failed to request channel disable");
ctx.response.status = Status.InternalServerError;
ctx.response.body = { message: "Failed to disable channel" };
ctx.response.body = { message: "Failed to request channel disable" };
}
};
}
Expand All @@ -328,7 +347,7 @@ export function handleEnableChannel(
}

const channel = await channelRepo.findByIdIncludeDeleted(id);
if (!channel || !channel.deletedAt) {
if (!channel || channel.status !== ChannelStatus.DISABLED) {
ctx.response.status = Status.NotFound;
ctx.response.body = { message: "Disabled channel not found" };
return;
Expand All @@ -340,21 +359,27 @@ export function handleEnableChannel(
return;
}

await channelRepo.restore(id);
// Intent only — the watcher flips `status` back to "enabled" once the
// quorum-authorized enable_channel call is confirmed on-chain. Re-enable
// reuses the same on-chain enable action.
const updated = await channelRepo.setPendingAction(
id,
ChannelPendingAction.ENABLE,
);

log.debug("id", id);
log.debug("channelContractId", channel.channelContractId);
log.event("channel re-enabled");
log.event("channel re-enable requested (pending on-chain confirmation)");

ctx.response.status = Status.OK;
ctx.response.status = Status.Accepted;
ctx.response.body = {
message: "Channel re-enabled",
data: formatChannel({ ...channel, deletedAt: null } as typeof channel),
message: "Channel re-enable requested; pending on-chain confirmation",
data: formatChannel(updated),
};
} catch (error) {
log.error(error, "failed to re-enable channel");
log.error(error, "failed to request channel re-enable");
ctx.response.status = Status.InternalServerError;
ctx.response.body = { message: "Failed to re-enable channel" };
ctx.response.body = { message: "Failed to request channel re-enable" };
}
};
}
Expand Down
5 changes: 5 additions & 0 deletions src/http/v1/public/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ async function returnCouncilSummary(
assetCode: ch.assetCode,
assetContractId: ch.assetContractId,
label: ch.label,
// Confirmed on-chain status so providers converge on disabled channels
// (withdraw-only) rather than treating them as absent.
status: ch.status,
})),
providers: providers.map((p) => {
let parsedJurisdictions: string[] | null = null;
Expand Down Expand Up @@ -130,6 +133,7 @@ export function buildPublicRouter(deps: { log: Logger }): Router {
assetCode: ch.assetCode,
assetContractId: ch.assetContractId,
label: ch.label,
status: ch.status,
})),
providers: providers.map((p) => {
let parsedJurisdictions: string[] | null = null;
Expand Down Expand Up @@ -207,6 +211,7 @@ export function buildPublicRouter(deps: { log: Logger }): Router {
assetCode: ch.assetCode,
assetContractId: ch.assetContractId,
label: ch.label,
status: ch.status,
})),
};
} catch (error) {
Expand Down
25 changes: 25 additions & 0 deletions src/persistence/drizzle/entity/council-channel.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ export const councilChannel = pgTable("council_channels", {
assetCode: text("asset_code").notNull(),
assetContractId: text("asset_contract_id"),
label: text("label"),
// Lifecycle status reflecting CONFIRMED on-chain state. Written ONLY by the
// event-watcher (sole authoritative writer) from the channel-auth
// ChannelStateChanged event — never by the HTTP endpoints. "disabled" means
// withdraw-only; providers converge on this via the public channel query.
status: text("status").notNull().default("enabled"),
// UX-only optimistic marker: which lifecycle action the council requested but
// that has not yet been confirmed on-chain ("enable" | "disable" | null). Set
// by the endpoint, cleared by the watcher on confirmation. NOT authoritative.
pendingAction: text("pending_action"),
// Cached on-chain state (refreshed on demand)
totalDeposited: bigint("total_deposited", { mode: "bigint" }),
totalWithdrawn: bigint("total_withdrawn", { mode: "bigint" }),
Expand All @@ -27,5 +36,21 @@ export const councilChannel = pgTable("council_channels", {
),
]);

/** Confirmed on-chain lifecycle status of a channel. */
export const ChannelStatus = {
ENABLED: "enabled",
DISABLED: "disabled",
} as const;
export type ChannelStatusValue =
(typeof ChannelStatus)[keyof typeof ChannelStatus];

/** Optimistic, UX-only marker for an unconfirmed lifecycle request. */
export const ChannelPendingAction = {
ENABLE: "enable",
DISABLE: "disable",
} as const;
export type ChannelPendingActionValue =
(typeof ChannelPendingAction)[keyof typeof ChannelPendingAction];

export type CouncilChannel = typeof councilChannel.$inferSelect;
export type NewCouncilChannel = typeof councilChannel.$inferInsert;
9 changes: 9 additions & 0 deletions src/persistence/drizzle/migration/0006_add_channel_status.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- UC6 asset-lifecycle: channels carry an explicit on-chain status instead of
-- being hidden via deletedAt when disabled. `status` is the sole authoritative
-- field written by the event-watcher from the channel-auth ChannelStateChanged
-- event; `pending_action` is an optimistic UX-only marker set by the endpoint
-- and cleared on confirmation. Existing (non-deleted) channels are enabled;
-- any previously soft-deleted channels migrate to disabled-but-visible.
ALTER TABLE council_channels ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'enabled';
ALTER TABLE council_channels ADD COLUMN IF NOT EXISTS pending_action TEXT;
UPDATE council_channels SET status = 'disabled', deleted_at = NULL WHERE deleted_at IS NOT NULL;
Loading
Loading