From a3666f9c7ba7892050887293c54c5f7cad41afaa Mon Sep 17 00:00:00 2001 From: shitikyan Date: Tue, 23 Jun 2026 21:02:23 +0400 Subject: [PATCH] =?UTF-8?q?feat(l2ps):=20SR-4=20WI-C=20=E2=80=94=20finaliz?= =?UTF-8?q?eRfq=20transcript=20anchor=20on=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ties WI-B's terminal state to WI-3's transcript anchor: on an agreed (accepted) negotiate-rfq, export the signed channel transcript and — per the disclosure policy (DACS-3 §8.7) — anchor an encrypted copy, returning the channelTranscriptRef the rfq output carries. finalizeRfq orchestrates exportTranscript (sign the ordered messages) → anchorEncryptedTranscript (encrypt-to-members + SR-2 anchor). Policy: - none: transcript stays local, nothing anchored - encrypted-anchored-recommended: anchor only on explicit consent - encrypted-anchored-required: MUST anchor; a null result throws (the phase fails, §8.7) Refuses to finalize a non-accepted negotiation; requires an L2PS instance when the policy actually anchors. The anchor call is injectable so the orchestration is unit-tested without a live node (the real anchor deploys an SR-2 storage program). 6 tests cover all four policy paths + the two guards, using a real signed offer→counter→accept so the exported transcript is genuinely CCI-signed. WI-D (AgreementDocument co-sign) needs DACS-3 §8.5.1; WI-E (devnet E2E) needs a node running the messaging server. Co-Authored-By: Claude Opus 4.7 --- src/l2ps/channel/finalize.ts | 123 +++++++++++++++++++++ src/l2ps/channel/index.ts | 8 ++ src/tests/l2ps/finalize.test.ts | 186 ++++++++++++++++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 src/l2ps/channel/finalize.ts create mode 100644 src/tests/l2ps/finalize.test.ts diff --git a/src/l2ps/channel/finalize.ts b/src/l2ps/channel/finalize.ts new file mode 100644 index 0000000..53a076f --- /dev/null +++ b/src/l2ps/channel/finalize.ts @@ -0,0 +1,123 @@ +/** + * SR-4 WI-C — finalise a negotiate-rfq session: on agreement, export the + * signed channel transcript and (per disclosure policy, DACS-3 §8.7) + * anchor an encrypted copy, returning the `channelTranscriptRef` that the + * negotiate-rfq output carries. + * + * This is the orchestration that ties WI-B's terminal state to WI-3's + * transcript anchor: `exportTranscript` (sign the ordered messages) → + * `anchorEncryptedTranscript` (encrypt-to-members + SR-2 anchor). Policy + * semantics (§8.7): + * - `none` — transcript stays local; nothing anchored. + * - `encrypted-anchored-recommended` — anchor only on explicit consent. + * - `encrypted-anchored-required` — MUST anchor; a null result fails + * the phase. + * + * The anchor call is injectable so the orchestration is unit-tested + * without a live node (the real anchor deploys an SR-2 storage program). + */ + +import type { Demos } from "../../websdk" +import type { ClaimReference } from "../../identity/cci" +import type L2PS from "../l2ps" +import type { + AnchorEncryptedTranscriptOpts, + AttestationRef, + TranscriptDisclosurePolicy, +} from "../anchor" +import { anchorEncryptedTranscript } from "../anchor" +import { exportTranscript } from "./transcript" +import type { ChannelMessage, ChannelTranscript } from "./types" +import type { RfqOutcome, RfqState } from "./negotiate" + +/** Minimal RfqSession surface — structural for testability. */ +export interface RfqLike { + readonly state: RfqState + outcome(): RfqOutcome +} + +/** Minimal ChannelSession surface — structural for testability. */ +export interface ChannelSessionView { + readonly channelId: string + readonly members: ReadonlyArray + messages(): ReadonlyArray +} + +export interface FinalizeRfqOpts { + rfq: RfqLike + session: ChannelSessionView + /** This party's CCI claim (signs the transcript); must be a member. */ + signer: ClaimReference + demos: Demos + /** Required when the policy actually anchors (not for `none`). */ + l2ps?: L2PS + policy: TranscriptDisclosurePolicy + /** Needed to anchor under `encrypted-anchored-recommended`. */ + consent?: boolean + /** + * Injectable anchor implementation; defaults to the real + * `anchorEncryptedTranscript`. Tests pass a fake to avoid deploying + * a storage program. + */ + anchor?: ( + opts: AnchorEncryptedTranscriptOpts, + ) => Promise +} + +export interface RfqFinalizeResult { + /** The signed, ordered transcript (always produced). */ + transcript: ChannelTranscript + /** + * The anchored attestation reference, or null when the policy did not + * anchor (`none`, or `recommended` without consent). + */ + channelTranscriptRef: AttestationRef | null +} + +export async function finalizeRfq( + opts: FinalizeRfqOpts, +): Promise { + if (opts.rfq.state !== "accepted") { + throw new Error( + `finalizeRfq: negotiation is "${opts.rfq.state}", not "accepted" — ` + + "only an agreed RFQ produces a transcript anchor", + ) + } + + const transcript = await exportTranscript({ + channelId: opts.session.channelId, + members: [...opts.session.members], + messages: opts.session.messages(), + signers: [{ claim: opts.signer, demos: opts.demos }], + }) + + if (opts.policy === "none") { + return { transcript, channelTranscriptRef: null } + } + + if (!opts.l2ps) { + throw new Error( + `finalizeRfq: policy "${opts.policy}" requires an L2PS instance to encrypt the transcript`, + ) + } + + const anchorFn = opts.anchor ?? anchorEncryptedTranscript + const ref = await anchorFn({ + transcript, + l2ps: opts.l2ps, + demos: opts.demos, + signer: opts.signer, + policy: opts.policy, + consent: opts.consent, + }) + + // §8.7: under `required`, the absence of an anchor fails the phase. + if (opts.policy === "encrypted-anchored-required" && !ref) { + throw new Error( + "finalizeRfq: policy is 'encrypted-anchored-required' but anchoring " + + "produced no attestation — phase fails", + ) + } + + return { transcript, channelTranscriptRef: ref } +} diff --git a/src/l2ps/channel/index.ts b/src/l2ps/channel/index.ts index 3384703..409ca31 100644 --- a/src/l2ps/channel/index.ts +++ b/src/l2ps/channel/index.ts @@ -70,3 +70,11 @@ export { type RfqEndBody, type StandingProposal, } from "./negotiate" + +export { + finalizeRfq, + type FinalizeRfqOpts, + type RfqFinalizeResult, + type RfqLike, + type ChannelSessionView, +} from "./finalize" diff --git a/src/tests/l2ps/finalize.test.ts b/src/tests/l2ps/finalize.test.ts new file mode 100644 index 0000000..29d51bf --- /dev/null +++ b/src/tests/l2ps/finalize.test.ts @@ -0,0 +1,186 @@ +import { Demos, DemosWebAuth } from "@/websdk" +import { demosClaimRefForAddress, type ClaimReference } from "@/identity/cci" +import { + ChannelSession, + RfqSession, + finalizeRfq, + type ChannelMessage, +} from "@/l2ps/channel" +import type { + AnchorEncryptedTranscriptOpts, + AttestationRef, +} from "@/l2ps/anchor" + +const CHANNEL = "ch-finalize-1" + +async function newConnectedDemos(): Promise<{ + demos: Demos + claim: ClaimReference +}> { + const auth = new DemosWebAuth() + await auth.create() + const demos = new Demos() + await demos.connectWallet(auth.keypair.privateKey as Uint8Array) + return { + demos, + claim: demosClaimRefForAddress(await demos.getEd25519Address()), + } +} + +/** Run a real signed offer→accept so the session holds a verifiable transcript. */ +async function agreedSession() { + const alice = await newConnectedDemos() + const bob = await newConnectedDemos() + const members = [alice.claim, bob.claim] + const aSes = new ChannelSession({ + channelId: CHANNEL, + members, + me: alice.claim, + demos: alice.demos, + }) + const bSes = new ChannelSession({ + channelId: CHANNEL, + members, + me: bob.claim, + demos: bob.demos, + }) + await aSes.open() + await bSes.open() + + const aRfq = new RfqSession({ + me: alice.claim, + send: async opts => { + const m = await aSes.sendOutgoing(opts) + await bSes.receiveIncoming(m) + bRfq.onIncoming(m) + return m + }, + }) + const bRfq = new RfqSession({ + me: bob.claim, + send: async opts => { + const m = await bSes.sendOutgoing(opts) + await aSes.receiveIncoming(m) + aRfq.onIncoming(m) + return m + }, + }) + + await aRfq.offer({ price: 100 }) + await bRfq.counter({ price: 90 }) + await aRfq.accept() + return { alice, aSes, aRfq } +} + +const okAnchor = + (captured: AnchorEncryptedTranscriptOpts[]) => + async (o: AnchorEncryptedTranscriptOpts): Promise => { + captured.push(o) + return { anchor: "0xsp-addr", contentHash: "0xhash" } + } + +describe("finalizeRfq — WI-C transcript anchor on terminal", () => { + it("exports a signed transcript and anchors under `required`", async () => { + const { alice, aSes, aRfq } = await agreedSession() + const captured: AnchorEncryptedTranscriptOpts[] = [] + const res = await finalizeRfq({ + rfq: aRfq, + session: aSes, + signer: alice.claim, + demos: alice.demos, + l2ps: {} as never, // anchor is faked; l2ps only needs to be present + policy: "encrypted-anchored-required", + anchor: okAnchor(captured), + }) + expect(res.transcript.messages.map(m => m.sequence)).toEqual([1, 2, 3]) + expect(res.transcript.signatures.length).toBe(1) // signed by alice + expect(res.channelTranscriptRef).toEqual({ + anchor: "0xsp-addr", + contentHash: "0xhash", + }) + expect(captured[0].policy).toBe("encrypted-anchored-required") + }) + + it("policy `none` exports the transcript but does not anchor", async () => { + const { alice, aSes, aRfq } = await agreedSession() + let anchored = false + const res = await finalizeRfq({ + rfq: aRfq, + session: aSes, + signer: alice.claim, + demos: alice.demos, + policy: "none", + anchor: async () => { + anchored = true + return null + }, + }) + expect(res.channelTranscriptRef).toBeNull() + expect(anchored).toBe(false) + expect(res.transcript.messages.length).toBe(3) + }) + + it("`recommended` without consent does not anchor (ref null)", async () => { + const { alice, aSes, aRfq } = await agreedSession() + const res = await finalizeRfq({ + rfq: aRfq, + session: aSes, + signer: alice.claim, + demos: alice.demos, + l2ps: {} as never, + policy: "encrypted-anchored-recommended", + consent: false, + // mimic the real helper: recommended + no consent → null + anchor: async o => (o.consent === true ? { anchor: "a", contentHash: "h" } : null), + }) + expect(res.channelTranscriptRef).toBeNull() + }) + + it("`required` fails the phase when anchoring returns null", async () => { + const { alice, aSes, aRfq } = await agreedSession() + await expect( + finalizeRfq({ + rfq: aRfq, + session: aSes, + signer: alice.claim, + demos: alice.demos, + l2ps: {} as never, + policy: "encrypted-anchored-required", + anchor: async () => null, + }), + ).rejects.toThrow(/phase fails/) + }) + + it("refuses to finalize a non-accepted negotiation", async () => { + const alice = await newConnectedDemos() + const fakeRfq = { state: "open" as const, outcome: () => ({ state: "open" as const }) } + const fakeSession = { + channelId: CHANNEL, + members: [alice.claim], + messages: () => [] as ChannelMessage[], + } + await expect( + finalizeRfq({ + rfq: fakeRfq, + session: fakeSession, + signer: alice.claim, + demos: alice.demos, + policy: "none", + }), + ).rejects.toThrow(/not "accepted"/) + }) + + it("requires an L2PS instance when the policy anchors", async () => { + const { alice, aSes, aRfq } = await agreedSession() + await expect( + finalizeRfq({ + rfq: aRfq, + session: aSes, + signer: alice.claim, + demos: alice.demos, + policy: "encrypted-anchored-required", + // no l2ps, no anchor override + }), + ).rejects.toThrow(/requires an L2PS instance/) + }) +})