From 8fe98493c5deec67c8b517ffd8d3984529fa4f33 Mon Sep 17 00:00:00 2001 From: Gorka Date: Mon, 15 Jun 2026 14:42:15 -0300 Subject: [PATCH] =?UTF-8?q?feat(channels):=20on-chain=20asset=20lifecycle?= =?UTF-8?q?=20=E2=80=94=20watcher=20is=20sole=20status=20writer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel-auth ChannelStateChanged event is now the authoritative source of channel status. Adds a council_channels.status column ('enabled'|'disabled') plus an optimistic pending_action UX marker, written via migration 0006. - event-watcher: decode + handle channel_state_changed → setStatusByContractId (the ONLY path that writes status); clears pending_action on confirmation. - enable/disable/re-enable endpoints record intent only (pending_action) and return 202; they never write status ahead of confirmed on-chain state. The quorum tx is signed client-side. Disable no longer soft-deletes. - public channel queries expose status so providers converge on disabled channels (withdraw-only) instead of seeing them disappear. - tests: event decode, repository status/pending semantics, API pending flow. --- deno.json | 2 +- .../event-watcher.service.test.ts | 60 ++++++++++++ .../event-watcher/event-watcher.service.ts | 24 ++++- .../event-watcher/event-watcher.types.ts | 15 ++- src/core/service/event-watcher/index.ts | 34 ++++++- src/http/v1/council/channels.ts | 53 +++++++--- src/http/v1/public/routes.ts | 5 + .../drizzle/entity/council-channel.entity.ts | 25 +++++ .../migration/0006_add_channel_status.sql | 9 ++ .../drizzle/migration/meta/_journal.json | 7 ++ .../repository/council-channel.repository.ts | 58 ++++++++--- tests/deno.lock | 4 + .../integration/api/council-channels.test.ts | 42 ++++++-- .../council-channel.repository.test.ts | 96 +++++++++++++++---- 14 files changed, 376 insertions(+), 58 deletions(-) create mode 100644 src/persistence/drizzle/migration/0006_add_channel_status.sql diff --git a/deno.json b/deno.json index 110a58f..6feb17a 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight-protocol/council-platform", - "version": "0.4.24", + "version": "0.5.0", "license": "MIT", "exports": "./src/main.ts", "tasks": { diff --git a/src/core/service/event-watcher/event-watcher.service.test.ts b/src/core/service/event-watcher/event-watcher.service.test.ts index 0665191..3de3214 100644 --- a/src/core/service/event-watcher/event-watcher.service.test.ts +++ b/src/core/service/event-watcher/event-watcher.service.test.ts @@ -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 diff --git a/src/core/service/event-watcher/event-watcher.service.ts b/src/core/service/event-watcher/event-watcher.service.ts index 4ea7b3f..a06d4bf 100644 --- a/src/core/service/event-watcher/event-watcher.service.ts +++ b/src/core/service/event-watcher/event-watcher.service.ts @@ -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, @@ -11,6 +11,7 @@ const KNOWN_TOPICS: Record = { contract_initialized: "contract_initialized", provider_added: "provider_added", provider_removed: "provider_removed", + channel_state_changed: "channel_state_changed", }; function decodeAddress(val: xdr.ScVal): string { @@ -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({ diff --git a/src/core/service/event-watcher/event-watcher.types.ts b/src/core/service/event-watcher/event-watcher.types.ts index 85506fb..fa86af2 100644 --- a/src/core/service/event-watcher/event-watcher.types.ts +++ b/src/core/service/event-watcher/event-watcher.types.ts @@ -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 { diff --git a/src/core/service/event-watcher/index.ts b/src/core/service/event-watcher/index.ts index 8ed88f9..32167e4 100644 --- a/src/core/service/event-watcher/index.ts +++ b/src/core/service/event-watcher/index.ts @@ -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. @@ -37,7 +41,7 @@ const activeWatchers = new Map(); // 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); @@ -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); diff --git a/src/http/v1/council/channels.ts b/src/http/v1/council/channels.ts index b1b55ea..8a906e5 100644 --- a/src/http/v1/council/channels.ts +++ b/src/http/v1/council/channels.ts @@ -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); @@ -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; @@ -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, @@ -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" }; } }; } @@ -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; @@ -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" }; } }; } diff --git a/src/http/v1/public/routes.ts b/src/http/v1/public/routes.ts index ef861d9..83a8860 100644 --- a/src/http/v1/public/routes.ts +++ b/src/http/v1/public/routes.ts @@ -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; @@ -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; @@ -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) { diff --git a/src/persistence/drizzle/entity/council-channel.entity.ts b/src/persistence/drizzle/entity/council-channel.entity.ts index 8e35c26..b017a9f 100644 --- a/src/persistence/drizzle/entity/council-channel.entity.ts +++ b/src/persistence/drizzle/entity/council-channel.entity.ts @@ -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" }), @@ -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; diff --git a/src/persistence/drizzle/migration/0006_add_channel_status.sql b/src/persistence/drizzle/migration/0006_add_channel_status.sql new file mode 100644 index 0000000..6c38cc2 --- /dev/null +++ b/src/persistence/drizzle/migration/0006_add_channel_status.sql @@ -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; diff --git a/src/persistence/drizzle/migration/meta/_journal.json b/src/persistence/drizzle/migration/meta/_journal.json index 0cc89a2..58c089d 100644 --- a/src/persistence/drizzle/migration/meta/_journal.json +++ b/src/persistence/drizzle/migration/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1779379200000, "tag": "0005_add_provider_jurisdictions", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1781913600000, + "tag": "0006_add_channel_status", + "breakpoints": true } ] } diff --git a/src/persistence/drizzle/repository/council-channel.repository.ts b/src/persistence/drizzle/repository/council-channel.repository.ts index fe6f5c3..79c69f2 100644 --- a/src/persistence/drizzle/repository/council-channel.repository.ts +++ b/src/persistence/drizzle/repository/council-channel.repository.ts @@ -1,6 +1,9 @@ -import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { BaseRepository } from "@/persistence/drizzle/repository/base.repository.ts"; import { + type ChannelPendingActionValue, + ChannelStatus, + type ChannelStatusValue, type CouncilChannel, councilChannel, type NewCouncilChannel, @@ -54,12 +57,54 @@ export class CouncilChannelRepository extends BaseRepository< .where( and( eq(councilChannel.councilId, councilId), - isNotNull(councilChannel.deletedAt), + isNull(councilChannel.deletedAt), + eq(councilChannel.status, ChannelStatus.DISABLED), ), ) .orderBy(councilChannel.createdAt); } + /** + * Authoritative status write — the ONLY path that mutates `status`. Called by + * the event-watcher when a ChannelStateChanged event is confirmed on-chain. + * Clears any optimistic `pendingAction` marker. Scoped by council so a + * channel id reused across councils can't be cross-written. Returns the + * updated row, or undefined if no matching channel exists. + */ + async setStatusByContractId( + councilId: string, + contractId: string, + status: ChannelStatusValue, + ): Promise { + const [result] = await this.db + .update(councilChannel) + .set({ status, pendingAction: null, updatedAt: new Date() }) + .where( + and( + eq(councilChannel.councilId, councilId), + eq(councilChannel.channelContractId, contractId), + ), + ) + .returning(); + return result; + } + + /** + * Sets the optimistic UX-only `pendingAction` marker. Never touches `status` + * (that is the watcher's job). Used by the enable/disable endpoints. + */ + async setPendingAction( + id: string, + action: ChannelPendingActionValue | null, + ): Promise { + const [result] = await this.db + .update(councilChannel) + .set({ pendingAction: action, updatedAt: new Date() }) + .where(eq(councilChannel.id, id)) + .returning(); + return result; + } + async findByContractIdIncludeDeleted( contractId: string, ): Promise { @@ -81,13 +126,4 @@ export class CouncilChannelRepository extends BaseRepository< .limit(1); return result; } - - async restore(id: string): Promise { - const [result] = await this.db - .update(councilChannel) - .set({ deletedAt: null, updatedAt: new Date() }) - .where(eq(councilChannel.id, id)) - .returning(); - return result; - } } diff --git a/tests/deno.lock b/tests/deno.lock index f7fe616..f6c5781 100644 --- a/tests/deno.lock +++ b/tests/deno.lock @@ -13,6 +13,7 @@ "jsr:@std/bytes@1": "1.0.6", "jsr:@std/collections@^1.1.3": "1.1.7", "jsr:@std/crypto@1": "1.1.0", + "jsr:@std/dotenv@~0.225.6": "0.225.6", "jsr:@std/encoding@0.224.0": "0.224.0", "jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10", @@ -98,6 +99,9 @@ "@std/crypto@1.1.0": { "integrity": "b8d6d0a6377a32b213af2661ed7bf1062d94feac0c57def5526a8e74a95c3ec8" }, + "@std/dotenv@0.225.6": { + "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" + }, "@std/encoding@0.224.0": { "integrity": "efb6dca97d3e9c31392bd5c8cfd9f9fc9decf5a1f4d1f78af7900a493bcf89b5" }, diff --git a/tests/integration/api/council-channels.test.ts b/tests/integration/api/council-channels.test.ts index e39f375..4094517 100644 --- a/tests/integration/api/council-channels.test.ts +++ b/tests/integration/api/council-channels.test.ts @@ -8,11 +8,13 @@ import { newNoop } from "@/utils/logger/index.ts"; import { createMockContext } from "../../test_app.ts"; import { ADMIN_KEYPAIR, + drizzleClient, ensureInitialized, resetDb, seedChannel, seedCouncilMetadata, } from "../../test_helpers.ts"; +import { CouncilChannelRepository } from "@/persistence/drizzle/repository/council-channel.repository.ts"; import { handleAddChannel, @@ -23,6 +25,17 @@ import { handleRemoveChannel, } from "@/http/v1/council/channels.ts"; +// Simulates the event-watcher confirming an on-chain ChannelStateChanged event — +// the ONLY path that flips authoritative status. HTTP endpoints set pending only. +const channelRepo = new CouncilChannelRepository(drizzleClient); +function confirmOnChain(contractId: string, enabled: boolean) { + return channelRepo.setStatusByContractId( + "default", + contractId, + enabled ? "enabled" : "disabled", + ); +} + const adminState = { session: { sub: ADMIN_KEYPAIR.publicKey(), @@ -184,7 +197,7 @@ Deno.test("GET /council/channels/:id - returns 404 for non-existent", async () = // DELETE /council/channels/:id (disable) // --------------------------------------------------------------------------- -Deno.test("DELETE /council/channels/:id - disables channel", async () => { +Deno.test("DELETE /council/channels/:id - requests disable (pending, not authoritative)", async () => { await ensureInitialized(); await resetDb(); await seedCouncilMetadata(); @@ -199,8 +212,16 @@ Deno.test("DELETE /council/channels/:id - disables channel", async () => { await handleRemoveChannel({ log: newNoop() })(ctx); const res = getResponse(); - assertEquals(res.status, 200); - assertEquals(res.body.message, "Channel disabled"); + // 202: intent recorded, awaiting on-chain confirmation. The endpoint sets the + // optimistic marker but must NOT flip authoritative status. + assertEquals(res.status, 202); + assertEquals(res.body.data.pendingAction, "disable"); + assertEquals(res.body.data.status, "enabled"); + + // DB confirms status is untouched until the watcher confirms the chain. + const stored = await channelRepo.findById(channel.id); + assertEquals(stored?.status, "enabled"); + assertEquals(stored?.pendingAction, "disable"); }); // --------------------------------------------------------------------------- @@ -214,15 +235,16 @@ Deno.test("POST /council/channels/:id/enable - re-enables disabled channel", asy const channel = await seedChannel({ channelContractId: TEST_CONTRACT_ID }); - // First disable it + // Disable request + simulated on-chain confirmation (the watcher's write). const disableCtx = createMockContext({ method: "DELETE", params: { id: channel.id }, state: { ...adminState }, }); await handleRemoveChannel({ log: newNoop() })(disableCtx.ctx); + await confirmOnChain(channel.channelContractId, false); - // Then re-enable + // Then re-enable — endpoint records intent for the now-disabled channel. const { ctx, getResponse } = createMockContext({ method: "POST", params: { id: channel.id }, @@ -231,8 +253,10 @@ Deno.test("POST /council/channels/:id/enable - re-enables disabled channel", asy await handleEnableChannel({ log: newNoop() })(ctx); const res = getResponse(); - assertEquals(res.status, 200); - assertEquals(res.body.message, "Channel re-enabled"); + assertEquals(res.status, 202); + assertEquals(res.body.data.pendingAction, "enable"); + // Authoritative status is still disabled until the chain confirms re-enable. + assertEquals(res.body.data.status, "disabled"); }); // --------------------------------------------------------------------------- @@ -247,13 +271,14 @@ Deno.test("GET /council/channels/disabled - lists disabled channels", async () = const ch = await seedChannel({ channelContractId: TEST_CONTRACT_ID }); await seedChannel({ channelContractId: TEST_CONTRACT_ID_2 }); - // Disable one + // Disable one and confirm it on-chain (the watcher's authoritative write). const disableCtx = createMockContext({ method: "DELETE", params: { id: ch.id }, state: { ...adminState }, }); await handleRemoveChannel({ log: newNoop() })(disableCtx.ctx); + await confirmOnChain(ch.channelContractId, false); const { ctx, getResponse } = createMockContext({ method: "GET", @@ -266,6 +291,7 @@ Deno.test("GET /council/channels/disabled - lists disabled channels", async () = assertEquals(res.status, 200); assertEquals(res.body.data.length, 1); assertEquals(res.body.data[0].channelContractId, TEST_CONTRACT_ID); + assertEquals(res.body.data[0].status, "disabled"); }); // --------------------------------------------------------------------------- diff --git a/tests/integration/repository/council-channel.repository.test.ts b/tests/integration/repository/council-channel.repository.test.ts index f080d50..53a4e7b 100644 --- a/tests/integration/repository/council-channel.repository.test.ts +++ b/tests/integration/repository/council-channel.repository.test.ts @@ -81,7 +81,7 @@ Deno.test("listAll - returns only non-deleted channels", async () => { assertEquals(all[0].id, ch1.id); }); -Deno.test("listDisabled - returns only soft-deleted channels", async () => { +Deno.test("listDisabled - returns channels with disabled status", async () => { await ensureInitialized(); await resetDb(); @@ -93,48 +93,106 @@ Deno.test("listDisabled - returns only soft-deleted channels", async () => { channelContractId: "CDIS2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", }); - await repo.delete(ch1.id); + // Disable via the authoritative status write (what the watcher does). + await repo.setStatusByContractId( + "default", + ch1.channelContractId, + "disabled", + ); const disabled = await repo.listDisabled("default"); assertEquals(disabled.length, 1); assertEquals(disabled[0].id, ch1.id); + assertEquals(disabled[0].status, "disabled"); +}); + +Deno.test("listAll - includes disabled (not soft-deleted) channels with status", async () => { + await ensureInitialized(); + await resetDb(); + + const ch1 = await seedChannel({ + channelContractId: + "CLAL1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + }); + await repo.setStatusByContractId( + "default", + ch1.channelContractId, + "disabled", + ); + + // Disabled channels stay visible to providers via listAll (not hidden). + const all = await repo.listAll("default"); + assertEquals(all.length, 1); + assertEquals(all[0].status, "disabled"); }); -Deno.test("findByIdIncludeDeleted - returns soft-deleted records", async () => { +Deno.test("setPendingAction - sets optimistic marker without changing status", async () => { await ensureInitialized(); await resetDb(); const channel = await seedChannel(); - await repo.delete(channel.id); + assertEquals(channel.status, "enabled"); - const found = await repo.findByIdIncludeDeleted(channel.id); - assertExists(found); - assertEquals(found.id, channel.id); - assertExists(found.deletedAt); + const updated = await repo.setPendingAction(channel.id, "disable"); + // status is unchanged — only the watcher may write it. + assertEquals(updated.status, "enabled"); + assertEquals(updated.pendingAction, "disable"); }); -Deno.test("restore - clears deletedAt", async () => { +Deno.test("setStatusByContractId - flips status and clears pendingAction", async () => { await ensureInitialized(); await resetDb(); const channel = await seedChannel(); - await repo.delete(channel.id); + await repo.setPendingAction(channel.id, "disable"); - // Verify deleted - const deleted = await repo.findByContractId( + const updated = await repo.setStatusByContractId( "default", channel.channelContractId, + "disabled", ); - assertEquals(deleted, undefined); + assertExists(updated); + assertEquals(updated.status, "disabled"); + assertEquals(updated.pendingAction, null); - // Restore - await repo.restore(channel.id); + // Re-enable path: status flips back, marker cleared again. + await repo.setPendingAction(channel.id, "enable"); + const reEnabled = await repo.setStatusByContractId( + "default", + channel.channelContractId, + "enabled", + ); + assertExists(reEnabled); + assertEquals(reEnabled.status, "enabled"); + assertEquals(reEnabled.pendingAction, null); +}); + +Deno.test("setStatusByContractId - is scoped by council", async () => { + await ensureInitialized(); + await resetDb(); + + const channel = await seedChannel(); + const result = await repo.setStatusByContractId( + "other-council", + channel.channelContractId, + "disabled", + ); + assertEquals(result, undefined); +}); + +Deno.test("findByIdIncludeDeleted - returns records regardless of status", async () => { + await ensureInitialized(); + await resetDb(); - // Verify restored - const restored = await repo.findByContractId( + const channel = await seedChannel(); + await repo.setStatusByContractId( "default", channel.channelContractId, + "disabled", ); - assertExists(restored); - assertEquals(restored.deletedAt, null); + + const found = await repo.findByIdIncludeDeleted(channel.id); + assertExists(found); + assertEquals(found.id, channel.id); + assertEquals(found.status, "disabled"); });