Skip to content
Closed
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
123 changes: 123 additions & 0 deletions src/l2ps/channel/finalize.ts
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
}

Comment on lines +35 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 RfqLike.outcome() is declared but never used

finalizeRfq only reads rfq.state; it never calls rfq.outcome(). Keeping outcome() in the structural interface forces every test stub and future implementor to provide a method that this function ignores. Dropping it from RfqLike (while keeping outcome() on RfqSession itself) 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!

/** 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 recommended + no consent still requires l2ps

The !opts.l2ps guard fires unconditionally for every non-none policy, but DACS-3 §8.7 says recommended without consent produces no anchor — the transcript stays local, same as none. A caller who correctly omits l2ps (because they know consent is withheld and the real anchor won't run) receives a misleading "requires an L2PS instance to encrypt the transcript" error instead of { transcript, channelTranscriptRef: null }. The test suite covers recommended+no-consent with a dummy l2ps: {} as never; the case of recommended+no-consent+no-l2ps is not exercised and throws today.

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 }
}
8 changes: 8 additions & 0 deletions src/l2ps/channel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,11 @@ export {
type RfqEndBody,
type StandingProposal,
} from "./negotiate"

export {
finalizeRfq,
type FinalizeRfqOpts,
type RfqFinalizeResult,
type RfqLike,
type ChannelSessionView,
} from "./finalize"
186 changes: 186 additions & 0 deletions src/tests/l2ps/finalize.test.ts
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(res.transcript.signatures).toHaveLength(1)".

See more on https://sonarcloud.io/project/issues?id=kynesyslabs_sdks&issues=AZ71b_j78hSCovrgOxry&open=AZ71b_j78hSCovrgOxry&pullRequest=97
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(res.transcript.messages).toHaveLength(3)".

See more on https://sonarcloud.io/project/issues?id=kynesyslabs_sdks&issues=AZ71b_j78hSCovrgOxrz&open=AZ71b_j78hSCovrgOxrz&pullRequest=97
})

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/)
})
})