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
30 changes: 25 additions & 5 deletions specs/schemes/exact/scheme_exact_canton.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
5 changes: 4 additions & 1 deletion typescript/.changeset/add-canton-mechanism.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
275 changes: 250 additions & 25 deletions typescript/packages/mechanisms/canton/src/exact/facilitator/scheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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<string, PendingSettle>();

/**
* Construct the facilitator-side Canton exact scheme.
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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<SettleResponse> {
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 } : {}),
};
}

Expand All @@ -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 } : {}),
};
Expand Down
Loading
Loading