-
Notifications
You must be signed in to change notification settings - Fork 2
feat(l2ps): SR-4 WI-C — finalizeRfq transcript anchor on terminal #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ClaimReference> | ||
| messages(): ReadonlyArray<ChannelMessage> | ||
| } | ||
|
|
||
| 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<AttestationRef | null> | ||
| } | ||
|
|
||
| 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<RfqFinalizeResult> { | ||
| 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(), | ||
|
Comment on lines
+80
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The |
||
| 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 } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AttestationRef> => { | ||
| 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 | ||
|
Check warning on line 96 in src/tests/l2ps/finalize.test.ts
|
||
| 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) | ||
|
Check warning on line 120 in src/tests/l2ps/finalize.test.ts
|
||
| }) | ||
|
|
||
| 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/) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RfqLike.outcome()is declared but never usedfinalizeRfqonly readsrfq.state; it never callsrfq.outcome(). Keepingoutcome()in the structural interface forces every test stub and future implementor to provide a method that this function ignores. Dropping it fromRfqLike(while keepingoutcome()onRfqSessionitself) would be the leaner contract.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!