From eb9d9652ce2aa3ab5cea0c5a191fbcf02cdcdb95 Mon Sep 17 00:00:00 2001 From: PhilBot <9mmnwvp6vs@privaterelay.appleid.com> Date: Wed, 30 Sep 2026 11:25:43 +0000 Subject: [PATCH] fix(canton): require confirmed funds before settle success Success is returned only when the funds-moved read proves delivery. A timeout or unreadable confirmation returns settlement_pending so core can retry settle once; that retry re-reads the same submission and then succeeds or fails terminally. Co-authored-by: PhilBot --- specs/schemes/exact/scheme_exact_canton.md | 30 +- typescript/.changeset/add-canton-mechanism.md | 5 +- .../canton/src/exact/facilitator/scheme.ts | 275 ++++++++++++++++-- .../canton/src/ledger/transfer-factory.ts | 66 +++-- .../mechanisms/canton/src/signer-factory.ts | 29 ++ .../packages/mechanisms/canton/src/signer.ts | 25 +- .../test/integrations/exact-canton.test.ts | 9 +- .../canton/test/unit/ledger.test.ts | 45 +++ .../canton/test/unit/settle.test.ts | 244 ++++++++++++++++ 9 files changed, 666 insertions(+), 62 deletions(-) create mode 100644 typescript/packages/mechanisms/canton/test/unit/settle.test.ts diff --git a/specs/schemes/exact/scheme_exact_canton.md b/specs/schemes/exact/scheme_exact_canton.md index f1179871bf..f3e1d071c4 100644 --- a/specs/schemes/exact/scheme_exact_canton.md +++ b/specs/schemes/exact/scheme_exact_canton.md @@ -303,9 +303,11 @@ already-spent holdings, which the ledger rejects. The replay guard is therefore native and on-ledger — no off-chain deduplication store is required. A facilitator MUST **relay** the signed transfer to settle. It MUST NOT treat a -previously observed `updateId` as settlement: a read of a completed update is -replayable and moves no funds, whereas relaying the signed transfer is -single-use by construction. +previously observed `updateId` as settlement of a new payment: a read of a +completed update is replayable and moves no funds, whereas relaying the signed +transfer is single-use by construction. The single `settlement_pending` retry +is not such a payment: it re-reads the relay this facilitator just submitted +and succeeds only when that read proves funds moved. ## Concurrency & Retry @@ -337,7 +339,24 @@ After verification succeeds: created (a pending resolution would create one — which the preapproval gate in Rule 7 already excludes). -4. Return `SettlementResponse` with the ledger `updateId`. +4. Return `SettlementResponse` with the ledger `updateId`. `success` is true + only when step 3 proved funds moved. A committed update whose events could + not be read is not success. + +5. **Unreadable confirmation.** If the relay was accepted but a timeout, + transport error, 5xx, or an unreadable funds-moved read means confirmation + is not yet known, the facilitator MAY return the non-terminal + `settlement_pending` (see [x402 v2 §9](../../x402-specification-v2.md#9-error-handling)) + with a non-empty `transaction` (the `updateId`, or the submission id when + the update id is not known yet). The resource server retries `settle` once + with the same payload. That retry MUST NOT relay again. It re-reads the + same submission and returns `success: true` only if funds moved. Otherwise + it returns a terminal failure: `invalid_exact_canton_execute_failed` when + the read shows the transfer did not deliver, or + `unexpected_canton_ledger_error` when the read is still unreadable. There + is no further retry and no off-chain idempotency store. A later settle of + the same payload is a new relay; once the input holdings are spent the + ledger rejects it. ## Error Reason Codes @@ -354,7 +373,8 @@ After verification succeeds: | `invalid_exact_canton_expired` | `executeBefore` is past or within the safety margin. | | `invalid_exact_canton_self_payment` | Proven sender equals the facilitator / `feePayer` party. | | `invalid_exact_canton_execute_failed` | The relayed transfer was rejected on execution — e.g. an input holding was already spent (concurrent settlement) or funds were insufficient. Transient input contention SHOULD be retried. | -| `unexpected_canton_ledger_error` | Participant read failure, ledger rejection, or timeout not covered above. | +| `unexpected_canton_ledger_error` | Participant read failure, ledger rejection, or timeout not covered above. Terminal, including a `settlement_pending` retry that still cannot read confirmation. | +| `settlement_pending` | The relay was accepted but confirmation could not be read (timeout, transport error, 5xx, or an empty funds-moved read). Non-terminal. `transaction` MUST be non-empty. The caller retries `settle` once; that retry does not relay again. | ## References diff --git a/typescript/.changeset/add-canton-mechanism.md b/typescript/.changeset/add-canton-mechanism.md index 6536474cb9..254b457b4a 100644 --- a/typescript/.changeset/add-canton-mechanism.md +++ b/typescript/.changeset/add-canton-mechanism.md @@ -13,4 +13,7 @@ built on a bundled JSON Ledger API + Scan client and the official `@canton-network/core-tx-visualizer` hashing; an integrator may also inject their own `ClientCantonSigner` / `FacilitatorCantonSigner`. Canton Coin and CIP-56 registry tokens (e.g. USDCx) share the exact wire shape and differ only by -`extra.instrumentId.admin`. +`extra.instrumentId.admin`. Settle returns success only after the relayed +transaction is confirmed and funds moved. A timeout or unreadable confirmation +returns non-terminal `settlement_pending`; the resource server retries settle +once, which either confirms or fails terminally. There is no further retry. diff --git a/typescript/packages/mechanisms/canton/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/canton/src/exact/facilitator/scheme.ts index 8d2c73b879..2ac7a4cd40 100644 --- a/typescript/packages/mechanisms/canton/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/canton/src/exact/facilitator/scheme.ts @@ -4,8 +4,13 @@ * `verify` proves the payer-signed inline transfer against the merchant's * requirements (see verify-inline.ts). `settle` re-verifies, then relays the * signed transaction through the injected `FacilitatorCantonSigner` - * (ExecuteSubmission) and confirms funds actually moved before reporting success. + * (ExecuteSubmission) and reports success only after that read proves funds + * moved. A timeout or unreadable confirmation is `settlement_pending`. Core + * retries `settle` once with the same payload; that retry re-reads the same + * submission and then returns success or a terminal failure. There is no + * further retry and no replay cache. */ +import { createHash } from "node:crypto"; import type { Network, PaymentPayload, @@ -16,10 +21,54 @@ import type { } from "@x402/core/types"; import { CANTON_CAIP_FAMILY } from "../../constants.js"; import type { CantonErrorCode } from "../../types.js"; -import type { FacilitatorCantonSigner, CantonSchemeConfig } from "../../signer.js"; -import { verifyInlineTransfer } from "./verify-inline.js"; +import type { + ConfirmSubmissionArgs, + ExecuteResult, + FacilitatorCantonSigner, + CantonSchemeConfig, +} from "../../signer.js"; +import { verifyInlineTransfer, type InlineVerifyResult } from "./verify-inline.js"; import { SubmissionOutcomeUnknownError } from "../../ledger/transfer-factory.js"; +/** Core non-terminal settle code. `@x402/core` retries `settle` exactly once + * when `errorReason` is this value and `transaction` is non-empty. */ +const SETTLEMENT_PENDING = "settlement_pending"; + +/** Cap on in-flight pending settles remembered for that one retry. */ +const MAX_PENDING_SETTLES = 256; + +/** What one execute/confirm read means for the settle response. */ +type ExecuteClass = + | { kind: "confirmed"; updateId: string } + | { kind: "pending"; transaction: string } + | { kind: "rejected"; transaction: string } + | { kind: "unknown"; transaction: string }; + +/** One relay waiting for the core pending retry. Dropped on that retry. */ +interface PendingSettle extends ConfirmSubmissionArgs { + transaction: string; +} + +/** + * Classify a funds-moved read. `allowPending` is true only on the first settle + * of this payload; the retry must resolve to success or a terminal failure. + * + * @param exec - The execute or confirm read. + * @param allowPending - Whether an unreadable confirmation may stay non-terminal. + * @returns The settle class for this read. + */ +function classifyExecute(exec: ExecuteResult, allowPending: boolean): ExecuteClass { + const updateId = exec.updateId; + if (exec.transferred && exec.confirmInconclusive !== true && updateId.length > 0) { + return { kind: "confirmed", updateId }; + } + if (exec.confirmInconclusive === true) { + if (allowPending && updateId.length > 0) return { kind: "pending", transaction: updateId }; + return { kind: "unknown", transaction: updateId }; + } + return { kind: "rejected", transaction: updateId }; +} + /** Options for the Canton facilitator scheme. */ export interface CantonFacilitatorOptions extends CantonSchemeConfig { /** The Global Synchronizer id this facilitator settles on, advertised in the @@ -35,6 +84,10 @@ export interface CantonFacilitatorOptions extends CantonSchemeConfig { export class ExactCantonScheme implements SchemeNetworkFacilitator { readonly scheme = "exact"; readonly caipFamily = CANTON_CAIP_FAMILY; + /** In-flight relays keyed by the prepared-transaction hash. Holds only what + * the single `settlement_pending` retry needs in order to re-read. A later + * settle of the same payload relays again. */ + private readonly pendingSettle = new Map(); /** * Construct the facilitator-side Canton exact scheme. @@ -96,6 +149,8 @@ export class ExactCantonScheme implements SchemeNetworkFacilitator { /** * Verify, then relay the signed transaction and confirm funds moved. + * Success requires that confirmation. A timeout or unreadable confirmation + * returns `settlement_pending` so core can retry this call once. * * @param payload - The x402 payment payload (inline carriage). * @param requirements - The merchant's payment requirements. @@ -117,7 +172,14 @@ export class ExactCantonScheme implements SchemeNetworkFacilitator { ); } - let exec; + const key = createHash("sha256").update(v.preparedTransactionBytes).digest("hex"); + const pending = this.pendingSettle.get(key); + if (pending) { + this.pendingSettle.delete(key); + return this.confirmPending(pending, network); + } + + let exec: ExecuteResult; try { exec = await this.signer.executeSubmission({ preparedTransactionBytes: v.preparedTransactionBytes, @@ -129,31 +191,188 @@ export class ExactCantonScheme implements SchemeNetworkFacilitator { transferKind: v.transferKind, }); } catch (err) { - // An unknown outcome (execute committed but the result was unreadable) is - // NOT a definite rejection: reporting it as the retryable execute failure - // would invite the payer to re-pay. Surface it as the non-retryable - // ledger-read error instead. - if (err instanceof SubmissionOutcomeUnknownError) { - return this.settleFailure("unexpected_canton_ledger_error", network, v.payer); - } - return this.settleFailure("invalid_exact_canton_execute_failed", network, v.payer); + return this.settleFromExecuteError(err, key, v, network); + } + return this.settleFromExecuteResult(exec, key, v.payer, v.transferKind, network); + } + + /** + * Map a successful relay's funds-moved read onto success, pending, or failure. + * + * @param exec - The execute read. + * @param key - Prepared-transaction hash for the one pending retry. + * @param payer - The proven payer. + * @param transferKind - Funds-moved signal verify selected. + * @param network - The requirements' network. + * @returns The settle response. + */ + private settleFromExecuteResult( + exec: ExecuteResult, + key: string, + payer: string, + transferKind: "amulet" | "registry", + network: Network, + ): SettleResponse { + const classified = classifyExecute(exec, true); + if (classified.kind === "pending") { + this.rememberPending(key, { + transaction: classified.transaction, + payer, + transferKind, + updateId: exec.updateId, + }); } + return this.responseForClass(classified, network, payer); + } - // Funds-moved gate. A DEFINITE committed-zero-funds execute is not a - // settlement. But an INCONCLUSIVE funds-moved read (the execute committed and - // an updateId exists, yet movement could not be confirmed either way) is - // trusted as settled: the preapproval gate already excluded the pending case, - // so a committed transfer moved funds — reporting failure here would withhold - // the resource for a payment that most likely succeeded. - if (!exec.transferred && !exec.confirmInconclusive) { - return this.settleFailure("invalid_exact_canton_execute_failed", network, v.payer); + /** + * Map an execute throw. A definite refusal is terminal and retryable by the + * payer with fresh inputs. An unknown outcome that names the submission is + * the one non-terminal pending response. An unknown outcome with no id is + * terminal: core cannot retry a pending settle that has no transaction. + * + * @param err - The error thrown by `executeSubmission`. + * @param key - Prepared-transaction hash for the one pending retry. + * @param verified - The verify result, including payer and transfer kind. + * @param network - The requirements' network. + * @returns The settle response. + */ + private settleFromExecuteError( + err: unknown, + key: string, + verified: InlineVerifyResult, + network: Network, + ): SettleResponse { + if (!(err instanceof SubmissionOutcomeUnknownError)) { + return this.settleFailure("invalid_exact_canton_execute_failed", network, verified.payer); + } + const transaction = err.context.updateId || err.context.submissionId || ""; + if (!transaction || !verified.transferKind) { + return this.settleFailure("unexpected_canton_ledger_error", network, verified.payer); } + this.rememberPending(key, { + transaction, + payer: verified.payer, + transferKind: verified.transferKind, + ...(err.context.updateId !== undefined ? { updateId: err.context.updateId } : {}), + ...(err.context.submissionId !== undefined ? { submissionId: err.context.submissionId } : {}), + ...(err.context.beginExclusive !== undefined + ? { beginExclusive: err.context.beginExclusive } + : {}), + }); + return this.settlePending(transaction, network, verified.payer); + } + /** + * The one core retry: re-read the relay already submitted. Success only if + * that read proves funds moved. Still unreadable, or no confirm hook, is a + * terminal ledger error. A definite non-delivery is `execute_failed`. + * + * @param pending - The relay recorded when this payload first returned pending. + * @param network - The requirements' network. + * @returns The settle response. Never `settlement_pending`. + */ + private async confirmPending(pending: PendingSettle, network: Network): Promise { + const confirm = this.signer.confirmSubmission; + if (!confirm) { + return this.settleFailure( + "unexpected_canton_ledger_error", + network, + pending.payer, + pending.transaction, + ); + } + try { + const exec = await confirm({ + payer: pending.payer, + transferKind: pending.transferKind, + ...(pending.updateId !== undefined ? { updateId: pending.updateId } : {}), + ...(pending.submissionId !== undefined ? { submissionId: pending.submissionId } : {}), + ...(pending.beginExclusive !== undefined ? { beginExclusive: pending.beginExclusive } : {}), + }); + return this.responseForClass(classifyExecute(exec, false), network, pending.payer); + } catch { + return this.settleFailure( + "unexpected_canton_ledger_error", + network, + pending.payer, + pending.transaction, + ); + } + } + + /** + * Remember one in-flight relay for the pending retry, dropping the oldest + * entry when the map is at its cap. + * + * @param key - Prepared-transaction hash. + * @param pending - What the retry needs in order to re-read. + */ + private rememberPending(key: string, pending: PendingSettle): void { + const atCapacity = + this.pendingSettle.size >= MAX_PENDING_SETTLES && !this.pendingSettle.has(key); + if (atCapacity) { + const oldest = this.pendingSettle.keys().next().value; + if (typeof oldest === "string") this.pendingSettle.delete(oldest); + } + this.pendingSettle.set(key, pending); + } + + /** + * Build the settle response for a classified read. + * + * @param classified - The funds-moved classification. + * @param network - The requirements' network. + * @param payer - The proven payer. + * @returns The settle response. + */ + private responseForClass( + classified: ExecuteClass, + network: Network, + payer: string, + ): SettleResponse { + switch (classified.kind) { + case "confirmed": + return { success: true, payer, transaction: classified.updateId, network }; + case "pending": + return this.settlePending(classified.transaction, network, payer); + case "rejected": + return this.settleFailure( + "invalid_exact_canton_execute_failed", + network, + payer, + classified.transaction, + ); + case "unknown": + return this.settleFailure( + "unexpected_canton_ledger_error", + network, + payer, + classified.transaction, + ); + default: { + const unexpected: never = classified; + throw new Error(`unexpected settle class: ${String(unexpected)}`); + } + } + } + + /** + * Build the non-terminal pending response. `transaction` must be non-empty + * or core will not retry. + * + * @param transaction - Update id, or the submission id when the update is not known yet. + * @param network - The requirements' network. + * @param payer - The proven payer. + * @returns The pending settle response. + */ + private settlePending(transaction: string, network: Network, payer: string): SettleResponse { return { - success: true, - payer: v.payer, - transaction: exec.updateId, + success: false, + errorReason: SETTLEMENT_PENDING, + transaction, network, + ...(payer ? { payer } : {}), }; } @@ -163,13 +382,19 @@ export class ExactCantonScheme implements SchemeNetworkFacilitator { * @param reason - The Canton error code. * @param network - The requirements' network. * @param payer - The proven payer, when known. + * @param transaction - Update or submission id, when one is known. * @returns The failure response. */ - private settleFailure(reason: CantonErrorCode, network: Network, payer: string): SettleResponse { + private settleFailure( + reason: CantonErrorCode, + network: Network, + payer: string, + transaction = "", + ): SettleResponse { return { success: false, errorReason: reason, - transaction: "", + transaction, network, ...(payer ? { payer } : {}), }; diff --git a/typescript/packages/mechanisms/canton/src/ledger/transfer-factory.ts b/typescript/packages/mechanisms/canton/src/ledger/transfer-factory.ts index 14583d488b..42c27d5282 100644 --- a/typescript/packages/mechanisms/canton/src/ledger/transfer-factory.ts +++ b/typescript/packages/mechanisms/canton/src/ledger/transfer-factory.ts @@ -85,23 +85,38 @@ export interface TfExecuteResult { /** True when the settle tx provably moved funds (archived Amulet with no * pending TransferInstruction, or the CIP-56 Completed result). */ transferred: boolean; - /** True when the funds-moved read was inconclusive and `transferred` fell back - * to the committed-execute signal. */ + /** True when the funds-moved read could not be completed. `transferred` is + * then false: an unreadable transaction is not proof that funds moved. */ confirmInconclusive: boolean; } const DEFAULT_CONFIRM_RETRY = { attempts: 4, delayMs: 500 }; +/** Identifiers for a submission whose outcome could not be read, so a single + * follow-up settle can re-read it without relaying again. */ +export interface UnknownSubmissionContext { + /** Committed update id, when `/execute` or the completion stream returned one. */ + updateId?: string; + /** Submission id sent with `/execute`. Stable for this relay only. */ + submissionId?: string; + /** Completion-stream offset captured before `/execute`. */ + beginExclusive?: number; +} + /** The submission was accepted by the participant but its outcome could not be * read — it may be committing right now. Distinct from a definite refusal so - * /settle never reports an unknown outcome as a rejection. */ + * /settle never reports an unknown outcome as a retryable rejection. */ export class SubmissionOutcomeUnknownError extends Error { /** * Construct a submission-outcome-unknown error. * * @param cause - The underlying read failure. + * @param context - Submission identifiers for one confirmation retry. */ - constructor(readonly cause: unknown) { + constructor( + readonly cause: unknown, + readonly context: UnknownSubmissionContext = {}, + ) { super( `interactive submission accepted but its outcome could not be read: ${ cause instanceof Error ? cause.message : String(cause) @@ -155,7 +170,10 @@ export class TransferFactoryService { // timeout, a dropped connection, an unreadable body or a 5xx may have // reached the ledger — that outcome is unknown, never a rejection. if (isDefiniteExecuteRefusal(err)) throw err; - throw new SubmissionOutcomeUnknownError(err); + throw new SubmissionOutcomeUnknownError(err, { + submissionId: input.submissionId, + beginExclusive: offset0, + }); } // /execute is async: it normally answers `{}` with the updateId on the // completion stream. Everything below reads the outcome of a submission @@ -173,7 +191,10 @@ export class TransferFactoryService { // A completion carrying a non-zero status is a real refusal — nothing // moved. Any other read failure is an unknown outcome, never a rejection. if ((err as { code?: unknown } | null)?.code === "SUBMISSION_FAILED") throw err; - throw new SubmissionOutcomeUnknownError(err); + throw new SubmissionOutcomeUnknownError(err, { + submissionId: input.submissionId, + beginExclusive: offset0, + }); } } return this.confirmTransferred(input.payer, updateId, input.transferKind); @@ -181,8 +202,8 @@ export class TransferFactoryService { /** * Did the funds actually move under this updateId? Reads the payer's - * projection; an unreadable read is inconclusive (trust the commit), never - * "did not happen". + * projection. An unreadable read is inconclusive (`transferred: false`), + * never proof that funds moved and never a definite "did not happen". * * @param payer - The payer party whose projection to read. * @param updateId - The committed update to confirm. @@ -229,27 +250,26 @@ export class TransferFactoryService { } if (sawAnyEvent) { // Amulet emits an archived `Splice.Amulet:Amulet` as the consumed input - // (the positive "funds moved" signal). A registry token archives its own - // (unknown-to-us) Holding, so for a registry instrument the signal is the - // standard's Completed result tag, falling back to "committed + not - // pending" when the tag cannot be read. For Amulet, a created pending - // instruction means the input was only locked, not delivered. + // (the positive "funds moved" signal). A registry token's signal is the + // standard's Completed result tag; an unreadable tag is inconclusive, + // not a delivery. A created pending instruction means the input was + // only locked, not delivered. const completedByResult = transferCompletedFromResult(events); - return { - updateId, - transferred: isRegistry - ? (completedByResult ?? !sawPendingInstruction) - : sawArchivedAmulet && !sawPendingInstruction, - confirmInconclusive: false, - }; + if (isRegistry && completedByResult === undefined) { + return { updateId, transferred: false, confirmInconclusive: true }; + } + const transferred = isRegistry + ? completedByResult === true && !sawPendingInstruction + : sawArchivedAmulet && !sawPendingInstruction; + return { updateId, transferred, confirmInconclusive: false }; } if (i < cfg.attempts - 1) { await new Promise(res => setTimeout(res, cfg.delayMs)); } } // Inconclusive read after retries: the execute committed (we have an - // updateId) and the preapproval gate already excluded the Pending case. Trust - // the committed signal; flag it. - return { updateId, transferred: true, confirmInconclusive: true }; + // updateId) but this read did not prove funds moved. Callers must not + // report success from that alone. + return { updateId, transferred: false, confirmInconclusive: true }; } } diff --git a/typescript/packages/mechanisms/canton/src/signer-factory.ts b/typescript/packages/mechanisms/canton/src/signer-factory.ts index 78a0240bee..6a221bb25f 100644 --- a/typescript/packages/mechanisms/canton/src/signer-factory.ts +++ b/typescript/packages/mechanisms/canton/src/signer-factory.ts @@ -374,6 +374,35 @@ export function toFacilitatorCantonSigner( } }, + async confirmSubmission(args): Promise { + let updateId = args.updateId ?? ""; + if (!updateId && args.submissionId !== undefined && args.beginExclusive !== undefined) { + try { + updateId = await client.pollCompletionUpdateId( + config.userId, + args.payer, + args.submissionId, + args.beginExclusive, + ); + } catch (err) { + const code = (err as { code?: unknown } | null)?.code; + if (code === "SUBMISSION_FAILED") { + return { updateId: "", transferred: false, confirmInconclusive: false }; + } + return { updateId: "", transferred: false, confirmInconclusive: true }; + } + } + if (!updateId) { + return { updateId: "", transferred: false, confirmInconclusive: true }; + } + const result = await tfSvc.confirmTransferred(args.payer, updateId, args.transferKind); + return { + updateId: result.updateId, + transferred: result.transferred, + confirmInconclusive: result.confirmInconclusive, + }; + }, + async executeSubmission(args): Promise { // `signedBy` is the payer's own namespace fingerprint (the part after // `::`), which for a single-key external party IS its signing key's diff --git a/typescript/packages/mechanisms/canton/src/signer.ts b/typescript/packages/mechanisms/canton/src/signer.ts index 154f5fa909..ec8a09eaec 100644 --- a/typescript/packages/mechanisms/canton/src/signer.ts +++ b/typescript/packages/mechanisms/canton/src/signer.ts @@ -23,14 +23,27 @@ export interface PreapprovalView { /** Result of relaying the payer-signed transaction (ExecuteSubmission). */ export interface ExecuteResult { - /** Canton updateId of the committed transaction. */ + /** Canton updateId of the committed transaction. Empty when it is not known. */ updateId: string; - /** Whether funds actually moved to the merchant (committed-zero-funds → false). */ + /** True only when this read proved funds moved to the merchant. */ transferred: boolean; - /** True when the funds-moved read could not be confirmed either way. */ + /** True when the funds-moved read could not be completed. `transferred` is + * then false. */ confirmInconclusive?: boolean; } +/** Re-read of a relay this facilitator already submitted. Does not submit. */ +export interface ConfirmSubmissionArgs { + payer: string; + transferKind: "amulet" | "registry"; + /** Committed update id, when the first settle already learned it. */ + updateId?: string; + /** Submission id from the first relay, used when the update id is not known. */ + submissionId?: string; + /** Completion-stream offset from before that relay. */ + beginExclusive?: number; +} + /** * Client-side signer: the payer's self-custody key plus participant access to * resolve the transfer factory and interactive-prepare the transaction. The @@ -126,6 +139,12 @@ export interface FacilitatorCantonSigner { inputHoldingCids: string[]; refresh?: boolean; }): Promise; + /** + * Re-read a relay this process already submitted. Settle calls this once, + * on the `settlement_pending` retry, and only to confirm funds moved. + * Implementations must not submit again. + */ + confirmSubmission?(args: ConfirmSubmissionArgs): Promise; /** Relay the payer-signed transaction (ExecuteSubmission). Settle only. */ executeSubmission(args: { preparedTransactionBytes: Buffer; diff --git a/typescript/packages/mechanisms/canton/test/integrations/exact-canton.test.ts b/typescript/packages/mechanisms/canton/test/integrations/exact-canton.test.ts index 11caa426d4..6cdd1ef7e3 100644 --- a/typescript/packages/mechanisms/canton/test/integrations/exact-canton.test.ts +++ b/typescript/packages/mechanisms/canton/test/integrations/exact-canton.test.ts @@ -156,11 +156,10 @@ describe("exact/canton integration (CC, stubbed signers)", () => { expect(kinds).toEqual(["amulet"]); }); - // Fund-safety: an execute that COMMITTED but whose outcome could not be read is - // not a rejection. Reporting it as the retryable execute-failed reason would - // invite the payer to pay again, so settle must surface the non-retryable - // ledger-read error instead. - it("settle maps an unknown execute outcome to the non-retryable ledger error", async () => { + // An unknown execute outcome with no submission id cannot be retried as + // settlement_pending (core requires a non-empty transaction). It stays a + // terminal ledger error, not the retryable execute-failed reason. + it("settle maps an unknown execute outcome with no id to the terminal ledger error", async () => { const { reqs, payload } = await buildFlow(); const signer: FacilitatorCantonSigner = { ...facilitatorSigner([]), diff --git a/typescript/packages/mechanisms/canton/test/unit/ledger.test.ts b/typescript/packages/mechanisms/canton/test/unit/ledger.test.ts index 7402d7b49a..13329b8f5d 100644 --- a/typescript/packages/mechanisms/canton/test/unit/ledger.test.ts +++ b/typescript/packages/mechanisms/canton/test/unit/ledger.test.ts @@ -79,6 +79,18 @@ describe("execute failure classification", () => { expect(isDefiniteExecuteRefusal(err)).toBe(want === "refused"); }); } + + it("a timeout keeps the submission id for one confirmation retry", async () => { + const svc = service({ + interactiveSubmissionExecute: async () => { + throw new CantonError("aborted", "TIMEOUT"); + }, + }); + await expect(svc.execute(execInput())).rejects.toMatchObject({ + name: "SubmissionOutcomeUnknownError", + context: { submissionId: "s", beginExclusive: 1 }, + }); + }); }); describe("funds-moved confirmation", () => { @@ -89,6 +101,15 @@ describe("funds-moved confirmation", () => { CreatedEvent: { contractId: "c2", templateId } as never, }); + it("no events after commit is inconclusive, not a transfer", async () => { + const r = await service({}).execute(execInput()); + expect(r).toEqual({ + updateId: "1220-u", + transferred: false, + confirmInconclusive: true, + }); + }); + it("Amulet: archived input and nothing pending → transferred", async () => { const r = await service({}, [archivedAmulet]).execute(execInput()); expect(r.transferred).toBe(true); @@ -136,6 +157,30 @@ describe("funds-moved confirmation", () => { expect((await run("TransferInstructionResult_Pending")).transferred).toBe(false); expect(asked).toEqual([true, true]); }); + + it("registry: an unreadable result tag is inconclusive", async () => { + const r = await service( + { + getTransactionById: async () => ({ + updateId: "1220-u", + offset: 2, + events: [ + { + ExercisedEvent: { + contractId: "f", + templateId: "t", + choice: "TransferFactory_Transfer", + exerciseResult: {}, + }, + }, + ], + }), + }, + [], + ).execute(execInput("registry")); + expect(r.transferred).toBe(false); + expect(r.confirmInconclusive).toBe(true); + }); }); describe("completion parsing", () => { diff --git a/typescript/packages/mechanisms/canton/test/unit/settle.test.ts b/typescript/packages/mechanisms/canton/test/unit/settle.test.ts new file mode 100644 index 0000000000..c26e9e8d06 --- /dev/null +++ b/typescript/packages/mechanisms/canton/test/unit/settle.test.ts @@ -0,0 +1,244 @@ +/** + * Facilitator settle reports success only when the funds-moved read proves + * delivery. An unreadable confirmation is settlement_pending once; the next + * settle re-reads and then succeeds or fails terminally. It does not relay + * again, and it does not keep a replay cache after that retry. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { ExactCantonScheme } from "../../src/exact/facilitator/scheme.js"; +import { encodeInlinePaymentPayload } from "../../src/inline-payload.js"; +import { SubmissionOutcomeUnknownError } from "../../src/ledger/transfer-factory.js"; +import { decodePrepared } from "../../src/prepared-transfer.js"; +import type { ExecuteResult, FacilitatorCantonSigner } from "../../src/signer.js"; + +const FIX = fileURLToPath(new URL("../../src/__fixtures__/", import.meta.url)); +const CC_RAW = readFileSync(FIX + "mainnet-transfer-preapproval-0.1.21.b64", "utf8").trim(); +const CC = JSON.parse(readFileSync(FIX + "mainnet-0.1.21.json", "utf8")).transfer as { + sender: string; + receiver: string; + amount: string; + instrumentId: { admin: string; id: string }; +}; +const FAC = "facilitator::1220" + "ff".repeat(32); +const NETWORK = "canton:mainnet" as const; +const CC_FACTORY = (() => { + const decoded = decodePrepared(CC_RAW); + return decoded.nodes.find(node => node.nodeId === decoded.roots[0])!.exercise!.contractId!; +})(); + +beforeEach(() => { + const prep = decodePrepared(CC_RAW).preparationTime ?? 0n; + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Number(prep / 1000n) + 1000); +}); +afterEach(() => vi.useRealTimers()); + +function payload(): PaymentPayload { + return { + x402Version: 2, + accepted: { scheme: "exact", network: NETWORK } as never, + payload: { + ...encodeInlinePaymentPayload({ + preparedTransactionBytes: Buffer.from(CC_RAW, "base64"), + preparedTxHash: "ab".repeat(32), + signatureB64: Buffer.alloc(64, 7).toString("base64"), + }), + }, + }; +} + +function requirements(): PaymentRequirements { + return { + scheme: "exact", + network: NETWORK, + amount: "100000000", + asset: "CC", + payTo: CC.receiver, + maxTimeoutSeconds: 60, + extra: { + assetTransferMethod: "transfer-factory", + feePayer: FAC, + instrumentId: CC.instrumentId, + executeBeforeSeconds: 120, + }, + }; +} + +function signer(over: Partial = {}): FacilitatorCantonSigner { + return { + getAddresses: () => [FAC], + verifySignature: async () => ({ verified: true, preparedTxHashHex: "cd".repeat(32) }), + fetchPreapproval: async () => ({ + receiver: CC.receiver, + dso: CC.instrumentId.admin, + expiresAt: new Date(Date.now() + 1_000_000_000).toISOString(), + }), + registryBaseUrl: () => undefined, + resolveTransferFactoryId: async () => CC_FACTORY, + executeSubmission: async () => ({ updateId: "1220-settled", transferred: true }), + ...over, + }; +} + +describe("ExactCantonScheme.settle", () => { + it("returns success only when the read proves funds moved", async () => { + const scheme = new ExactCantonScheme(signer()); + const settle = await scheme.settle(payload(), requirements()); + expect(settle).toMatchObject({ success: true, transaction: "1220-settled" }); + }); + + it("returns execute_failed when the committed transfer did not deliver", async () => { + const scheme = new ExactCantonScheme( + signer({ + executeSubmission: async () => ({ + updateId: "1220-empty", + transferred: false, + confirmInconclusive: false, + }), + }), + ); + const settle = await scheme.settle(payload(), requirements()); + expect(settle.success).toBe(false); + expect(settle.errorReason).toBe("invalid_exact_canton_execute_failed"); + expect(settle.transaction).toBe("1220-empty"); + }); + + it("returns settlement_pending when confirmation is unreadable, then succeeds on one re-read", async () => { + const executes: string[] = []; + const confirms: string[] = []; + const scheme = new ExactCantonScheme( + signer({ + executeSubmission: async () => { + executes.push("execute"); + const pending: ExecuteResult = { + updateId: "1220-pending", + transferred: false, + confirmInconclusive: true, + }; + return pending; + }, + confirmSubmission: async args => { + confirms.push(args.updateId ?? ""); + return { updateId: "1220-pending", transferred: true }; + }, + }), + ); + + const first = await scheme.settle(payload(), requirements()); + expect(first).toMatchObject({ + success: false, + errorReason: "settlement_pending", + transaction: "1220-pending", + }); + + const second = await scheme.settle(payload(), requirements()); + expect(second).toMatchObject({ success: true, transaction: "1220-pending" }); + expect(executes).toEqual(["execute"]); + expect(confirms).toEqual(["1220-pending"]); + }); + + it("the pending retry fails terminally when the re-read is still unreadable", async () => { + const executes: string[] = []; + const scheme = new ExactCantonScheme( + signer({ + executeSubmission: async () => { + executes.push("execute"); + return { updateId: "1220-pending", transferred: false, confirmInconclusive: true }; + }, + confirmSubmission: async () => ({ + updateId: "1220-pending", + transferred: false, + confirmInconclusive: true, + }), + }), + ); + + const first = await scheme.settle(payload(), requirements()); + expect(first.errorReason).toBe("settlement_pending"); + + const second = await scheme.settle(payload(), requirements()); + expect(second.success).toBe(false); + expect(second.errorReason).toBe("unexpected_canton_ledger_error"); + expect(second.transaction).toBe("1220-pending"); + expect(executes).toEqual(["execute"]); + }); + + it("does not keep the pending entry after the retry, so a later settle relays again", async () => { + let calls = 0; + const scheme = new ExactCantonScheme( + signer({ + executeSubmission: async () => { + calls += 1; + return { updateId: "1220-pending", transferred: false, confirmInconclusive: true }; + }, + confirmSubmission: async () => ({ + updateId: "1220-pending", + transferred: false, + confirmInconclusive: true, + }), + }), + ); + + await scheme.settle(payload(), requirements()); + await scheme.settle(payload(), requirements()); + const third = await scheme.settle(payload(), requirements()); + expect(third.errorReason).toBe("settlement_pending"); + expect(calls).toBe(2); + }); + + it("returns settlement_pending for an unknown execute that names the submission", async () => { + const scheme = new ExactCantonScheme( + signer({ + executeSubmission: async () => { + throw new SubmissionOutcomeUnknownError(new Error("timeout"), { + submissionId: "sub-1", + beginExclusive: 4, + }); + }, + confirmSubmission: async args => { + expect(args.submissionId).toBe("sub-1"); + expect(args.beginExclusive).toBe(4); + return { updateId: "1220-later", transferred: true }; + }, + }), + ); + + const first = await scheme.settle(payload(), requirements()); + expect(first).toMatchObject({ + success: false, + errorReason: "settlement_pending", + transaction: "sub-1", + }); + const second = await scheme.settle(payload(), requirements()); + expect(second).toMatchObject({ success: true, transaction: "1220-later" }); + }); + + it("returns a terminal ledger error when an unknown execute names no submission", async () => { + const scheme = new ExactCantonScheme( + signer({ + executeSubmission: async () => { + throw new SubmissionOutcomeUnknownError(new Error("timeout")); + }, + }), + ); + const settle = await scheme.settle(payload(), requirements()); + expect(settle.success).toBe(false); + expect(settle.errorReason).toBe("unexpected_canton_ledger_error"); + expect(settle.transaction).toBe(""); + }); + + it("returns execute_failed for a definite execute refusal", async () => { + const scheme = new ExactCantonScheme( + signer({ + executeSubmission: async () => { + throw new Error("SUBMISSION_FAILED"); + }, + }), + ); + const settle = await scheme.settle(payload(), requirements()); + expect(settle.errorReason).toBe("invalid_exact_canton_execute_failed"); + }); +});