@ftptech/canton-agent-wallet 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1227,101 +1227,6 @@ allowedConsequenceChoices = []) {
1227
1227
  }
1228
1228
  return { exercise: rootNode.exercise, rootNodeId: rootId };
1229
1229
  }
1230
- /**
1231
- * Create-root analog of `assertSingleAllowedRootExercise`: assert the prepared
1232
- * transaction is EXACTLY a single top-level CREATE of the allowed template, with
1233
- * NO exercise anywhere (a value-moving choice is never a legitimate consequence of
1234
- * a bare consent create), EVERY node recognized, exactly ONE root that IS that
1235
- * create, and no orphan/extra-leg node.
1236
- *
1237
- * Used by the x402-direct onboarding arms (`createSenderConsent` /
1238
- * `createMerchantConsent`): the SenderConsent / MerchantConsent are created by the
1239
- * party SIGNING ITS OWN create (signatory = that party), so the prepared tx is a
1240
- * single CreateCommand → one CREATE root node. Creating a standing consent MOVES NO
1241
- * FUNDS; the ONLY parties it introduces are the signing party and the facilitator
1242
- * (observer). A relay-injected EXERCISE (an Allocation_ExecuteTransfer /
1243
- * TransferFactory_Transfer / CreateTransferCommand drain) or a second create/root is
1244
- * refused. Fail-closed in every case.
1245
- */
1246
- function assertSingleAllowedRootCreate(decoded,
1247
- /** Caller-intent `module:entity` of the created template (e.g.
1248
- * "X402Direct:SenderConsent"). Pinned against the create's decoded template when
1249
- * present; a create carrying no decodable template is tolerated (normalized wire)
1250
- * and identified by its principals at the call site. */
1251
- expectedCreatedQualifiedName) {
1252
- // (1) NO exercise of any choice anywhere. A consent create has no value-moving
1253
- // consequence exercise; an exercise node is a relay-injected drain.
1254
- if (decoded.exercises.length > 0) {
1255
- throw new PreparedTransferMismatchError(`relay-prepared consent-create transaction contains ${decoded.exercises.length} ` +
1256
- `exercise(s) ${decoded.exercises
1257
- .map((e) => JSON.stringify(e.choiceId))
1258
- .join(", ")} — expected a single bare create with no exercise — refusing to sign`);
1259
- }
1260
- // (2) Every node fully recognized (same fail-closed stance as the exercise arm).
1261
- for (const node of decoded.nodes) {
1262
- if (!node.recognizedVersion) {
1263
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) carried ` +
1264
- `under an unrecognized node version — refusing to sign`);
1265
- }
1266
- if (node.kind === undefined) {
1267
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) with an ` +
1268
- `unknown or ambiguous node type — refusing to sign`);
1269
- }
1270
- }
1271
- // (3) Exactly one root, and it must be the allowed CREATE.
1272
- if (decoded.roots.length !== 1) {
1273
- throw new PreparedTransferMismatchError(`relay-prepared transaction has ${decoded.roots.length} root nodes — expected exactly one ` +
1274
- `(${JSON.stringify(expectedCreatedQualifiedName)} create) — refusing to sign`);
1275
- }
1276
- const byId = new Map();
1277
- for (const node of decoded.nodes) {
1278
- if (byId.has(node.nodeId)) {
1279
- throw new PreparedTransferMismatchError(`relay-prepared transaction has duplicate node id ${JSON.stringify(node.nodeId)} — ` +
1280
- `refusing to sign (ambiguous node graph)`);
1281
- }
1282
- byId.set(node.nodeId, node);
1283
- }
1284
- const rootId = decoded.roots[0];
1285
- const rootNode = byId.get(rootId);
1286
- if (rootNode === undefined) {
1287
- throw new PreparedTransferMismatchError(`relay-prepared transaction root id ${JSON.stringify(rootId)} does not match any node — ` +
1288
- `refusing to sign`);
1289
- }
1290
- if (rootNode.kind !== "create" || rootNode.create === undefined) {
1291
- throw new PreparedTransferMismatchError(`relay-prepared transaction root node is not a ${JSON.stringify(expectedCreatedQualifiedName)} ` +
1292
- `create (got node type ${JSON.stringify(rootNode.kind ?? "unknown")}) — refusing to sign`);
1293
- }
1294
- // Pin the created template's module:entity when the node carries one (a normalized
1295
- // wire may omit it; the caller then self-anchors on the create's principals).
1296
- if (rootNode.create.templateQualifiedName !== undefined &&
1297
- rootNode.create.templateQualifiedName !== expectedCreatedQualifiedName) {
1298
- throw new PreparedTransferMismatchError(`relay-prepared create is of template ${JSON.stringify(rootNode.create.templateQualifiedName)} — ` +
1299
- `expected ${JSON.stringify(expectedCreatedQualifiedName)} — refusing to sign`);
1300
- }
1301
- // (4) No orphans: every node reachable from the single root.
1302
- const reachable = new Set();
1303
- const stack = [rootId];
1304
- while (stack.length > 0) {
1305
- const id = stack.pop();
1306
- if (reachable.has(id))
1307
- continue;
1308
- reachable.add(id);
1309
- const node = byId.get(id);
1310
- if (node === undefined) {
1311
- throw new PreparedTransferMismatchError(`relay-prepared transaction references child node id ${JSON.stringify(id)} that does not ` +
1312
- `exist — refusing to sign`);
1313
- }
1314
- for (const child of node.children)
1315
- stack.push(child);
1316
- }
1317
- const orphans = decoded.nodes.filter((nd) => !reachable.has(nd.nodeId));
1318
- if (orphans.length > 0) {
1319
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains ${orphans.length} node(s) not reachable from the root ` +
1320
- `(ids ${orphans.map((o) => JSON.stringify(o.nodeId)).join(", ")}) — refusing to sign ` +
1321
- `(possible tampered/compromised relay hiding an extra leg)`);
1322
- }
1323
- return { create: rootNode.create, rootNodeId: rootId };
1324
- }
1325
1230
  /* ────────────────────────────────────────────────────────────────────────
1326
1231
  * Shared SIGNED-Metadata checks (synchronizer + timing). Every field below is
1327
1232
  * in the proto's "Metadata information that needs to be signed" block, so the
@@ -1908,553 +1813,6 @@ export function assertPreparedAcceptMatches(preparedTransactionB64, expect) {
1908
1813
  // authority, never a third party's.
1909
1814
  assertActAsIsSender(decoded.actAs, expect.selfParty);
1910
1815
  }
1911
- /* ════════════════════════════════════════════════════════════════════════
1912
- * allocation-api (CIP-56 DVP) — verify-before-sign for the
1913
- * `AllocationFactory_Allocate` exercise.
1914
- *
1915
- * THREAT MODEL — identical to the cip56 / v1 arms, for the Allocation path.
1916
- * The agent asks the relay to PREPARE an `AllocationFactory_Allocate` (it LOCKS
1917
- * the agent's input holdings into an Allocation naming the facilitator as
1918
- * settlement.executor). A compromised relay could prepare a DIFFERENT
1919
- * allocation — swap the receiver, inflate the amount, name an ATTACKER (not the
1920
- * facilitator) as settlement.executor so the attacker can later steer
1921
- * Allocation_ExecuteTransfer, or widen the deadline window — and hand back its
1922
- * hash. Because the agent signs with its own key (the choice's sole controller
1923
- * is allocation.transferLeg.sender = the payer), a blind signature would
1924
- * authorize the attacker's allocation. We decode the prepared bytes with the
1925
- * SAME structural rigor (Daml-LF POSITIONAL record reads via entryByDeclOrder,
1926
- * fail-closed on duplicate/multi-member oneofs, parties matched BY TYPE not
1927
- * text) and assert each money/escrow-critical leaf equals CALLER INTENT at its
1928
- * declaration-order position.
1929
- *
1930
- * AllocationFactory_Allocate choiceArgument :: Record {
1931
- * [0] expectedAdmin : Party, -- the instrument admin (DSO)
1932
- * [1] allocation : AllocationSpecification,
1933
- * [2] requestedAt : Time,
1934
- * [3] inputHoldingCids : [ContractId Holding],
1935
- * [4] extraArgs : ExtraArgs
1936
- * }
1937
- * AllocationSpecification :: Record {
1938
- * [0] settlement : SettlementInfo {
1939
- * [0] executor : Party, -- MUST == facilitatorParty (executor)
1940
- * [1] settlementRef : Reference,
1941
- * [2] requestedAt : Time,
1942
- * [3] allocateBefore : Time, -- MUST be < settleBefore
1943
- * [4] settleBefore : Time,
1944
- * [5] meta : Metadata },
1945
- * [1] transferLegId : Text,
1946
- * [2] transferLeg : TransferLeg {
1947
- * [0] sender : Party, -- the payer (sole controller; act_as)
1948
- * [1] receiver : Party, -- MUST == merchant payTo from the 402
1949
- * [2] amount : Numeric, -- MUST == the required amount
1950
- * [3] instrumentId : InstrumentId { [0] admin : Party, [1] id : Text },
1951
- * [4] meta : Metadata }
1952
- * }
1953
- *
1954
- * FIELD-ORDER AUTHORITY (confirmed): the declared field ORDER above is CONFIRMED
1955
- * against the canonical Splice DAML source — Daml-LF encodes records POSITIONALLY
1956
- * by `with`-block field declaration order, so the canonical .daml IS the
1957
- * authoritative ground truth for these positions:
1958
- * - SettlementInfo, TransferLeg, AllocationSpecification, Reference:
1959
- * token-standard/splice-api-token-allocation-v1/daml/Splice/Api/Token/AllocationV1.daml
1960
- * - InstrumentId:
1961
- * token-standard/splice-api-token-holding-v1/daml/Splice/Api/Token/HoldingV1.daml
1962
- * - AllocationFactory_Allocate choice arg:
1963
- * token-standard/splice-api-token-allocation-instruction-v1/daml/Splice/Api/Token/AllocationInstructionV1.daml
1964
- * repo github.com/canton-network/splice @ commit
1965
- * b1767c6e9aba37a278faadd50b17b3e11b153a3f (main, fetched 2026-06-23). Every
1966
- * position pinned below matches that source verbatim (AllocationSpecification
1967
- * has 3 fields with transferLegId at [1] between settlement[0] and
1968
- * transferLeg[2]; SettlementInfo executor[0]/allocateBefore[3]/settleBefore[4];
1969
- * TransferLeg sender[0]/receiver[1]/amount[2]/instrumentId[3];
1970
- * InstrumentId admin[0]/id[1]; choice arg allocation at [1]).
1971
- * Daml-LF binds records POSITIONALLY, so the positional reads below
1972
- * (entryByDeclOrder) are the exact, fail-closed pin the cip56/v1 arms already
1973
- * use; this is a STRUCTURAL pin by declaration order, not a best-effort substring
1974
- * scan. Do NOT silently relax the positional reads to label-based ones to "make
1975
- * it work" against a live shape — that reintroduces the label/position divergence
1976
- * vector the cip56 arm documents. RESIDUAL: a live participant prepared-tx vector
1977
- * remains a nice-to-have cross-check (DevNet infra-blocked: synchronizer topology
1978
- * frozen since 2026-06-03) but is no longer blocking — the canonical source above
1979
- * is the field-order authority.
1980
- * ════════════════════════════════════════════════════════════════════════ */
1981
- /** The allocate choice on an AllocationFactory (allocation-api / DVP). */
1982
- const ALLOCATE_CHOICE = "AllocationFactory_Allocate";
1983
- /** Choices the honest AllocationFactory_Allocate consequence subtree carries —
1984
- * for Amulet the allocation creates an AmuletAllocation + LockedAmulet and
1985
- * archives the consumed input Amulet(s). Permitted ONLY as reachable
1986
- * consequences of the single Allocate root (never as a second root / orphan);
1987
- * sender/receiver/amount/executor/admin stay pinned by extractAllocation and
1988
- * the all-nodes foreign-party backstop still runs, so an injected redirect is
1989
- * refused. Kept conservative (Archive only) like the strict pay paths; widen
1990
- * ONLY against a confirmed live consequence subtree, never speculatively. */
1991
- const ALLOCATE_CONSEQUENCE_CHOICES = ["Archive"];
1992
- /**
1993
- * Extract the allocation's money/escrow-critical leaves from the
1994
- * `AllocationFactory_Allocate` choice argument, BY TYPE at each Daml
1995
- * DECLARATION-ORDER POSITION (what the participant binds), NOT by label — with
1996
- * the same label/position-divergence guard the cip56/v1 arms use. Fails closed
1997
- * on any wrong-type / missing money-critical field.
1998
- */
1999
- export function extractAllocation(chosenValue) {
2000
- const top = recordEntries(chosenValue);
2001
- // choiceArgument: [0]expectedAdmin [1]allocation [2]requestedAt
2002
- // [3]inputHoldingCids [4]extraArgs — read position 1 ONLY.
2003
- const allocationVal = entryByDeclOrder(top, 1, "allocation")?.value;
2004
- if (!allocationVal) {
2005
- throw new PreparedDecodeError("could not locate allocation record in AllocationFactory_Allocate choice argument");
2006
- }
2007
- // AllocationSpecification: [0]settlement [1]transferLegId [2]transferLeg.
2008
- const spec = recordEntries(allocationVal);
2009
- const settlementVal = entryByDeclOrder(spec, 0, "settlement")?.value;
2010
- const transferLegVal = entryByDeclOrder(spec, 2, "transferLeg")?.value;
2011
- if (!settlementVal)
2012
- throw new PreparedDecodeError("allocation.settlement missing");
2013
- if (!transferLegVal)
2014
- throw new PreparedDecodeError("allocation.transferLeg missing");
2015
- // SettlementInfo: [0]executor [1]settlementRef [2]requestedAt
2016
- // [3]allocateBefore [4]settleBefore [5]meta.
2017
- const settlement = recordEntries(settlementVal);
2018
- const executorE = entryByDeclOrder(settlement, 0, "executor");
2019
- const allocateBeforeE = entryByDeclOrder(settlement, 3, "allocateBefore");
2020
- const settleBeforeE = entryByDeclOrder(settlement, 4, "settleBefore");
2021
- const executor = executorE ? leafOf(executorE.value) : undefined;
2022
- const allocateBefore = allocateBeforeE ? leafOf(allocateBeforeE.value) : undefined;
2023
- const settleBefore = settleBeforeE ? leafOf(settleBeforeE.value) : undefined;
2024
- if (!executor || executor.kind !== "party") {
2025
- throw new PreparedDecodeError("allocation.settlement.executor is not a party");
2026
- }
2027
- // TransferLeg: [0]sender [1]receiver [2]amount [3]instrumentId [4]meta.
2028
- const leg = recordEntries(transferLegVal);
2029
- const senderE = entryByDeclOrder(leg, 0, "sender");
2030
- const receiverE = entryByDeclOrder(leg, 1, "receiver");
2031
- const amountE = entryByDeclOrder(leg, 2, "amount");
2032
- const instrE = entryByDeclOrder(leg, 3, "instrumentId");
2033
- const sender = senderE ? leafOf(senderE.value) : undefined;
2034
- const receiver = receiverE ? leafOf(receiverE.value) : undefined;
2035
- const amount = amountE ? leafOf(amountE.value) : undefined;
2036
- const instrumentVal = instrE?.value;
2037
- if (!sender || sender.kind !== "party") {
2038
- throw new PreparedDecodeError("allocation.transferLeg.sender is not a party");
2039
- }
2040
- if (!receiver || receiver.kind !== "party") {
2041
- throw new PreparedDecodeError("allocation.transferLeg.receiver is not a party");
2042
- }
2043
- if (!amount || amount.kind !== "numeric") {
2044
- throw new PreparedDecodeError("allocation.transferLeg.amount is not numeric");
2045
- }
2046
- if (!instrumentVal)
2047
- throw new PreparedDecodeError("allocation.transferLeg.instrumentId missing");
2048
- const instrument = readInstrument(instrumentVal);
2049
- return {
2050
- sender: sender.value,
2051
- receiver: receiver.value,
2052
- amount: amount.value,
2053
- instrumentAdmin: instrument.admin,
2054
- instrumentId: instrument.id,
2055
- executor: executor.value,
2056
- ...(allocateBefore && allocateBefore.kind === "timestamp"
2057
- ? { allocateBefore: allocateBefore.value }
2058
- : {}),
2059
- ...(settleBefore && settleBefore.kind === "timestamp"
2060
- ? { settleBefore: settleBefore.value }
2061
- : {}),
2062
- };
2063
- }
2064
- /**
2065
- * Assert the relay-returned `preparedTransaction` encodes EXACTLY the
2066
- * `AllocationFactory_Allocate` the agent intended. Throws
2067
- * `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError` if
2068
- * the bytes are not a decodable PreparedTransaction carrying a single allocate
2069
- * exercise. Call BEFORE signing the hash. Fail-closed: anything not positively
2070
- * proven to match the intent throws.
2071
- */
2072
- export function assertPreparedAllocationMatches(preparedTransactionB64, expect) {
2073
- const decoded = decodePrepared(preparedTransactionB64);
2074
- // Node-traversal invariant (shared with the cip56 / v1 arms): exactly one
2075
- // allowed ROOT exercise & no other; EVERY node recognized; EXACTLY ONE root
2076
- // that IS the single allowed AllocationFactory_Allocate exercise; no
2077
- // orphan/extra-leg node. Honest Amulet consequence (Archive of the locked
2078
- // input) whitelisted as a CONSEQUENCE only.
2079
- const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, ALLOCATE_CHOICE, "allocation", ALLOCATE_CONSEQUENCE_CHOICES);
2080
- const a = extractAllocation(ex.chosenValue);
2081
- // OWN-PARTY SENDER PIN (hardening) — asserted FIRST, fail-CLOSED. The
2082
- // AllocationFactory_Allocate controller is the sender, and the agent knows its
2083
- // OWN party (wallet.party) independently of the relay. The extracted
2084
- // transferLeg.sender MUST equal that own party. transferLeg.sender and
2085
- // transferLeg.receiver are BOTH Party leaves at adjacent declaration-order
2086
- // positions and the declared order is not yet confirmed against a live wire, so
2087
- // a real-wire transposition of those two (or a wrong-position sender) could
2088
- // otherwise slip past the type guard. Pinning the sender to the agent's own
2089
- // party neutralizes that transposition risk REGARDLESS of field order — a swap
2090
- // that puts any non-self party (e.g. the receiver/merchant) in the sender
2091
- // position is refused here before anything else runs.
2092
- if (a.sender !== expect.expectedSender) {
2093
- throw new PreparedTransferMismatchError(`relay-prepared allocation transferLeg.sender (got ${JSON.stringify(a.sender)}) is not ` +
2094
- `the agent's own party (${JSON.stringify(expect.expectedSender)}) — refusing to sign ` +
2095
- `(possible sender/receiver transposition or wrong-position sender from a tampered/` +
2096
- `compromised relay redirecting the locked funds)`);
2097
- }
2098
- const mismatches = [];
2099
- if (a.sender !== expect.sender) {
2100
- mismatches.push(`transferLeg.sender (got ${JSON.stringify(a.sender)}, intended ${JSON.stringify(expect.sender)})`);
2101
- }
2102
- if (a.receiver !== expect.receiver) {
2103
- mismatches.push(`transferLeg.receiver (got ${JSON.stringify(a.receiver)}, intended ${JSON.stringify(expect.receiver)})`);
2104
- }
2105
- if (canonicalAmount(a.amount) !== canonicalAmount(expect.amount)) {
2106
- mismatches.push(`transferLeg.amount (got ${JSON.stringify(a.amount)}, intended ${JSON.stringify(expect.amount)})`);
2107
- }
2108
- if (a.instrumentId !== expect.instrumentId) {
2109
- mismatches.push(`transferLeg.instrumentId.id (got ${JSON.stringify(a.instrumentId)}, intended ${JSON.stringify(expect.instrumentId)})`);
2110
- }
2111
- // settlement.executor MUST equal the facilitator party (escrow-trust anchor).
2112
- if (a.executor !== expect.executor) {
2113
- mismatches.push(`settlement.executor (got ${JSON.stringify(a.executor)}, intended ${JSON.stringify(expect.executor)})`);
2114
- }
2115
- // Only pin the admin if the caller supplied an independently-trusted value.
2116
- if (expect.instrumentAdmin !== undefined && a.instrumentAdmin !== expect.instrumentAdmin) {
2117
- mismatches.push(`transferLeg.instrumentId.admin (got ${JSON.stringify(a.instrumentAdmin)}, intended ${JSON.stringify(expect.instrumentAdmin)})`);
2118
- }
2119
- if (mismatches.length > 0) {
2120
- throw new PreparedTransferMismatchError(`relay-prepared allocation does not match intent: ${mismatches.join("; ")} — ` +
2121
- `refusing to sign (possible tampered/compromised relay redirecting funds or executor)`);
2122
- }
2123
- // The admin/dso must never be aliased to a money/escrow role — otherwise a
2124
- // relay could set the (unpinned) admin to the receiver/sender/executor and
2125
- // have the backstop exempt that party. Reject up front.
2126
- if (a.instrumentAdmin === expect.sender ||
2127
- a.instrumentAdmin === expect.receiver ||
2128
- a.instrumentAdmin === expect.executor) {
2129
- throw new PreparedTransferMismatchError(`relay-prepared allocation instrumentId.admin ${JSON.stringify(a.instrumentAdmin)} equals a ` +
2130
- `transfer/escrow party (sender/receiver/executor) — refusing to sign`);
2131
- }
2132
- // The executor must not be aliased to the sender or receiver — the executor is
2133
- // the facilitator (a distinct party); collapsing it onto a money party would
2134
- // defeat the escrow-trust separation.
2135
- if (a.executor === expect.sender || a.executor === expect.receiver) {
2136
- throw new PreparedTransferMismatchError(`relay-prepared allocation settlement.executor ${JSON.stringify(a.executor)} equals the ` +
2137
- `sender or receiver — refusing to sign (escrow-trust separation broken)`);
2138
- }
2139
- // DEADLINE FAIL-CLOSED (hardening). Both allocateBefore AND settleBefore MUST
2140
- // be present as Value.timestamp leaves. extractAllocation only populates these
2141
- // when the leaf is actually a `timestamp`; a relay that OMITS a deadline leaf
2142
- // or RE-TYPES it (e.g. a text/numeric/optional leaf at the settleBefore
2143
- // position) makes the field read back undefined. Previously the ordering +
2144
- // future-margin check was CONDITIONAL on both being present, so such a relay
2145
- // could make the client skip the check entirely. Now a missing/wrong-type
2146
- // deadline is itself a tamper signal: THROW (fail closed) rather than sign an
2147
- // allocation with no enforceable settle window.
2148
- if (a.allocateBefore === undefined || a.settleBefore === undefined) {
2149
- throw new PreparedTransferMismatchError(`relay-prepared allocation is missing a timestamp deadline ` +
2150
- `(allocateBefore present=${a.allocateBefore !== undefined}, ` +
2151
- `settleBefore present=${a.settleBefore !== undefined}) — refusing to sign ` +
2152
- `(a relay that omits or re-types a deadline leaf must not bypass the ` +
2153
- `ordering + future-margin check)`);
2154
- }
2155
- // allocateBefore < settleBefore (deadline ordering). The spec REQUIRES
2156
- // settleBefore strictly after allocateBefore; an inverted/equal window is a
2157
- // tamper signal (it would make the allocation un-executable or immediately
2158
- // reclaimable). Both are Value.timestamp µs since epoch.
2159
- {
2160
- let ab;
2161
- let sb;
2162
- try {
2163
- ab = BigInt(a.allocateBefore);
2164
- sb = BigInt(a.settleBefore);
2165
- }
2166
- catch {
2167
- throw new PreparedTransferMismatchError(`relay-prepared allocation has a non-integer deadline — refusing to sign`);
2168
- }
2169
- if (!(ab < sb)) {
2170
- throw new PreparedTransferMismatchError(`relay-prepared allocation has allocateBefore (${a.allocateBefore}) >= settleBefore ` +
2171
- `(${a.settleBefore}) — refusing to sign (invalid/un-executable deadline window)`);
2172
- }
2173
- // settleBefore must be in the future — a lapsed window can never settle.
2174
- const nowMs = expect.nowMs ?? Date.now();
2175
- const settleMs = Number(sb / 1000n);
2176
- if (Number.isFinite(settleMs) && settleMs <= nowMs) {
2177
- throw new PreparedTransferMismatchError(`relay-prepared allocation settleBefore (${new Date(settleMs).toISOString()}) is in the ` +
2178
- `past — refusing to sign (allocation could never be executed)`);
2179
- }
2180
- }
2181
- // Pin WHICH template + WHICH contract the choice runs against, and the SIGNED
2182
- // synchronizer + timing metadata. No-ops unless the caller supplies intent.
2183
- assertTemplateMatches(ex, expect.templateQualifiedName);
2184
- assertContractIdMatches(ex, expect.expectedContractId);
2185
- assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
2186
- assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2187
- // Cross-check the authoritative submitter: the allocate's sole controller is
2188
- // transferLeg.sender = the agent, so act_as must be exactly the agent.
2189
- assertActAsIsSender(decoded.actAs, expect.sender);
2190
- // Position-aware foreign-party backstop over the WHOLE signed message. Allowed
2191
- // recipients are {sender, receiver, executor} — the executor (facilitator) is a
2192
- // LEGITIMATE party of the allocation (it is settlement.executor / observer), so
2193
- // it must be in the allowed set or the honest allocation's own executor leaf
2194
- // would surface as foreign. The instrument admin (DSO) is allowed at its known
2195
- // root positions (expectedAdmin + instrumentId.admin = 2), value-excluded
2196
- // OUTSIDE its root position ONLY when pinned to an independently-trusted value.
2197
- const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2198
- assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver, expect.executor]), a.instrumentAdmin, 2 /* expectedAdmin + instrumentId.admin */, expect.instrumentAdmin !== undefined /* trusted iff caller pinned it */);
2199
- }
2200
- /* ════════════════════════════════════════════════════════════════════════
2201
- * allocation-api reclaim — verify-before-sign for `Allocation_Withdraw`
2202
- * (Track B "locked funds always recoverable").
2203
- *
2204
- * THREAT MODEL. The agent has an open `AmuletAllocation` (it locked its own
2205
- * holdings into an Allocation for a DvP that never settled / it wants to abort)
2206
- * and wants its funds BACK. The canonical recovery choice on
2207
- * `Splice.Api.Token.AllocationV1` is `Allocation_Withdraw`:
2208
- *
2209
- * choice Allocation_Withdraw : Allocation_WithdrawResult
2210
- * controller (view this).allocation.transferLeg.sender -- the SENDER ALONE
2211
- * with extraArgs : ExtraArgs
2212
- * -- returns Allocation_WithdrawResult { senderHoldingCids, meta }
2213
- * -- funds return to the sender BY CONSTRUCTION (no attacker-controllable
2214
- * destination is encodable in the choice argument).
2215
- * Confirmed against canton-network/splice@main, interface
2216
- * Splice.Api.Token.AllocationV1.
2217
- *
2218
- * Even though the funds return to the sender by the choice's own definition, the
2219
- * agent must NOT blind-sign a relay-prepared `Allocation_Withdraw`: a compromised
2220
- * relay could instead prepare (a) a DIFFERENT choice that DOES move value to the
2221
- * attacker (e.g. `Allocation_ExecuteTransfer`, a `TransferFactory_Transfer`
2222
- * drain), (b) a withdraw of a DIFFERENT allocation than the one the caller
2223
- * intended, (c) a withdraw with an extra/sibling outbound transfer leg as a
2224
- * consequence, or (d) the same choice submitted under a foreign party's
2225
- * authority. We therefore apply the SAME fail-closed structural gate the accept
2226
- * (claim) arm uses — accept is the closest analog (funds-IN, structural,
2227
- * own-party) — PLUS a REQUIRED contract-id pin (the caller names exactly which
2228
- * allocation to reclaim, closing the resolve→prepare TOCTOU) and the all-nodes
2229
- * foreign-party backstop with the ONLY allowed party being the agent itself
2230
- * (Allocation_Withdraw returns funds to the sender, so ANY other party anywhere
2231
- * — an injected outbound leg, a different receiver, an executor-as-payee — is a
2232
- * smuggled redirect and is rejected). Anything not positively proven to be a
2233
- * single self-submitted withdraw of the caller's own allocation throws.
2234
- * ════════════════════════════════════════════════════════════════════════ */
2235
- /** The reclaim choice on a Splice AmuletAllocation (funds-back / Track B). */
2236
- const WITHDRAW_CHOICE = "Allocation_Withdraw";
2237
- /** Choices the honest `Allocation_Withdraw` consequence subtree carries: it
2238
- * archives the consumed `AmuletAllocation` / `LockedAmulet` and recreates the
2239
- * sender's Amulet(s). Kept conservative (Archive only) like the strict pay /
2240
- * allocate paths — a value-MOVING choice is never whitelisted here, so an
2241
- * injected outbound drain is refused. Widen ONLY against a confirmed live
2242
- * consequence subtree, never speculatively; the foreign-party backstop is the
2243
- * real guard against a smuggled outbound leg regardless. */
2244
- // Honest Allocation_Withdraw consequences. With an expire-lock choice-context the
2245
- // withdraw unlocks the held Amulet back to its owner (the sender) via
2246
- // LockedAmulet_UnlockV2 (older ledgers: LockedAmulet_Unlock) and archives the
2247
- // consumed AmuletAllocation/LockedAmulet. These are SAFE to whitelist because the
2248
- // step-5 foreign-party backstop still runs over the WHOLE signed message with the
2249
- // agent (sender) as the ONLY permitted party — the unlock can therefore only
2250
- // return funds to the sender, never redirect them. A relay-injected outbound drain
2251
- // (Allocation_ExecuteTransfer / TransferFactory_Transfer / a second root) is still
2252
- // neither the root choice nor a whitelisted consequence, so it is refused.
2253
- const WITHDRAW_CONSEQUENCE_CHOICES = [
2254
- "Archive",
2255
- "LockedAmulet_UnlockV2",
2256
- "LockedAmulet_Unlock",
2257
- ];
2258
- /**
2259
- * Assert the relay-returned `preparedTransaction` for the reclaim path encodes
2260
- * EXACTLY a single `Allocation_Withdraw` exercise, on the caller's own
2261
- * allocation cid, submitted by the agent — and is NOT a different (value-moving)
2262
- * choice, a withdraw of a different contract, an outbound drain, or a
2263
- * foreign-party submission. Throws `PreparedTransferMismatchError` on any
2264
- * mismatch and `PreparedDecodeError` if the bytes are not a decodable
2265
- * PreparedTransaction. Call BEFORE signing the hash. Fail-closed: anything not
2266
- * positively proven to be a self-withdraw of the caller's own allocation throws.
2267
- */
2268
- export function assertPreparedAllocationWithdrawMatches(preparedTransactionB64, expect) {
2269
- const decoded = decodePrepared(preparedTransactionB64);
2270
- // (1) Shared node-traversal invariant: EXACTLY ONE root and it IS the
2271
- // Allocation_Withdraw exercise; EVERY node recognized; no orphan/extra-leg
2272
- // node. The honest withdraw's settlement consequences (Archive of the consumed
2273
- // AmuletAllocation / LockedAmulet) are whitelisted as CONSEQUENCES only — still
2274
- // bound to the single Withdraw root by the reachability check. A relay-injected
2275
- // outbound drain (Allocation_ExecuteTransfer / TransferFactory_Transfer /
2276
- // CreateTransferCommand / a second root) is refused: it is neither the root
2277
- // choice nor a whitelisted consequence.
2278
- const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, WITHDRAW_CHOICE, WITHDRAW_CHOICE, WITHDRAW_CONSEQUENCE_CHOICES);
2279
- // (2) Target-contract pin (CALLER INTENT — REQUIRED). The exercised
2280
- // Exercise.contract_id MUST equal the caller's allocationCid: this is the core
2281
- // "withdraw exactly the allocation I asked for" guarantee and closes the
2282
- // resolve→prepare TOCTOU (a relay resolving one allocation to us then preparing
2283
- // the withdraw against another).
2284
- assertContractIdMatches(ex, expect.allocationCid);
2285
- // (3) SIGNED timing metadata sanity (relay-chosen validity window).
2286
- assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2287
- // (4) act_as / controller = own party. The submitter (the authoritative
2288
- // act_as) must be exactly the agent. The Daml controller is
2289
- // allocation.transferLeg.sender; since act_as is the authoritative submitter
2290
- // and the choice's sole controller is the sender, pinning act_as == selfParty
2291
- // proves the agent is the sender exercising its own reclaim.
2292
- assertActAsIsSender(decoded.actAs, expect.selfParty);
2293
- // (5) Foreign-party / no-outbound-leg backstop over the WHOLE signed message.
2294
- // Allocation_Withdraw returns funds to the SENDER by construction, so the ONLY
2295
- // party permitted anywhere — root chosen_value, consequence/sibling nodes, any
2296
- // node's party metadata, Metadata.input_contracts — is the agent itself. ANY
2297
- // other party (an injected outbound transfer leg, a different receiver, an
2298
- // executor-as-payee, a foreign submitter leaf) is a smuggled redirect and is
2299
- // rejected. No admin/dso exemption is threaded (unlike accept/allocate): zero
2300
- // allowed admin slots, so a relay-supplied DSO cannot be aliased to an attacker
2301
- // and smuggled in. Widen ONLY if a confirmed live consequence subtree proves an
2302
- // honest non-self party must appear — never speculatively.
2303
- const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2304
- // Allowed parties: the sender (funds-back recipient) ALWAYS, plus — ONLY when
2305
- // the caller pinned them (lock-expiry path) — the facilitator (the allocation
2306
- // executor / lock holder, who is a stakeholder on the unlocked LockedAmulet and
2307
- // on disclosed contracts), the DSO (AmuletRules signatory referenced by the
2308
- // expire-lock context), and the RECEIVER (merchant). All are caller-intent, so a
2309
- // relay cannot smuggle a foreign recipient by aliasing it to one of them. On the
2310
- // no-lock (empty-context) path none is pinned and the set stays {sender} exactly.
2311
- //
2312
- // The receiver pin closes the "Design B" DIRECT gap: there the allocation's
2313
- // receiver is the MERCHANT (not the facilitator, as in "Design A" escrow where
2314
- // receiver == executor == facilitator), so the legitimate expire-lock
2315
- // choice-context references the merchant party — which is none of
2316
- // {self, facilitator, instrumentAdmin}, so the backstop would otherwise reject a
2317
- // VALID withdraw. We add EXACTLY the caller-pinned receiver (never a wildcard),
2318
- // so it remains fail-closed: an UNPINNED receiver is still rejected, and any
2319
- // party other than the exact pinned receiver is still rejected. Design A omits it
2320
- // (receiver == facilitator, already allowed) and behavior is unchanged.
2321
- const allowedWithdraw = new Set([expect.selfParty]);
2322
- if (expect.facilitator)
2323
- allowedWithdraw.add(expect.facilitator);
2324
- if (expect.receiver)
2325
- allowedWithdraw.add(expect.receiver);
2326
- assertNoForeignParties(leaves, allowedWithdraw, expect.instrumentAdmin /* DSO: AmuletRules signatory in the expire-lock ctx */, expect.instrumentAdmin !== undefined ? 4 : 0 /* AmuletRules + round + unlock refs */, expect.instrumentAdmin !== undefined /* trusted iff caller pinned it */);
2327
- }
2328
- /* ════════════════════════════════════════════════════════════════════════
2329
- * x402-direct (Design B "2-tx DIRECT") CONSENT CREATE — verify-before-sign for a
2330
- * `SenderConsent` / `MerchantConsent` create (the party captures its one-time
2331
- * standing authorization).
2332
- *
2333
- * THREAT MODEL. In the direct flow each party signs ONE standing consent ever — the
2334
- * SENDER a `SenderConsent {sender, facilitator}`, the MERCHANT a `MerchantConsent
2335
- * {merchant, facilitator}` (source of truth: daml/x402-direct/daml/X402Direct.daml;
2336
- * each template's signatory is the named party, observer = facilitator). The party
2337
- * (an external party whose key the agent-wallet holds) creates it directly, so the
2338
- * prepared tx is a single CreateCommand → one CREATE root. CREATING A STANDING
2339
- * CONSENT MOVES NO FUNDS.
2340
- *
2341
- * Even so the agent must NOT blind-sign: a compromised relay could instead prepare
2342
- * (a) a value-MOVING exercise (an `Allocation_ExecuteTransfer` /
2343
- * `TransferFactory_Transfer` / `ExternalPartyAmuletRules_CreateTransferCommand` /
2344
- * `Allocation_Withdraw` drain), (b) a consent naming a DIFFERENT facilitator (who
2345
- * could later mint a both-party consent and settle the agent's allocations), (c) an
2346
- * extra/sibling outbound leg, or (d) a foreign-party submission. We apply a
2347
- * fail-closed CREATE-root gate (the create analog of the escrow-accept/withdraw
2348
- * arms): EXACTLY one create of the expected template, NO exercise anywhere, act_as
2349
- * == the signing party, the consent's [0] self-party == the agent AND [1]
2350
- * facilitator == caller intent, and a foreign-party backstop whose ONLY allowed
2351
- * parties are {selfParty, facilitator} (the consent's two parties). ANY other party
2352
- * anywhere — a relay-substituted facilitator, an injected outbound leg, a foreign
2353
- * submitter leaf — is rejected.
2354
- *
2355
- * WHAT AN ATTACKER-TAMPERED CONSENT CREATE TRIPS:
2356
- * - swapped to a value-moving exercise → no-exercise + create-root pin
2357
- * - second/sibling leg or extra root → single-root + no-orphan
2358
- * - relay-substituted facilitator → [1] facilitator pin + backstop
2359
- * - self-party not the agent → [0] self-party pin + act_as pin
2360
- * - act_as a foreign party → act_as == selfParty pin
2361
- * - any smuggled recipient party anywhere → foreign-party backstop
2362
- * Anything not positively proven to be a single self-submitted create of the
2363
- * caller's own consent among exactly {selfParty, facilitator} throws. Fail-closed.
2364
- * ════════════════════════════════════════════════════════════════════════ */
2365
- /** Created template `module:entity` names for the two standing consents. */
2366
- const SENDER_CONSENT_CREATED_QUALIFIED_NAME = "X402Direct:SenderConsent";
2367
- const MERCHANT_CONSENT_CREATED_QUALIFIED_NAME = "X402Direct:MerchantConsent";
2368
- /** Read the {self, facilitator} parties of a consent create-args record at their
2369
- * declaration positions ([0] self, [1] facilitator). Both SenderConsent and
2370
- * MerchantConsent share this shape (the [0] label differs: sender vs merchant).
2371
- * Returns undefined if the record is not the consent shape at those positions. */
2372
- function readConsentCreatePrincipals(argument, selfLabel) {
2373
- let entries;
2374
- try {
2375
- entries = recordEntries(argument);
2376
- }
2377
- catch {
2378
- return undefined;
2379
- }
2380
- let self;
2381
- let facilitator;
2382
- try {
2383
- const selfE = entryByDeclOrder(entries, 0, selfLabel);
2384
- const facE = entryByDeclOrder(entries, 1, "facilitator");
2385
- self = selfE ? leafOf(selfE.value) : undefined;
2386
- facilitator = facE ? leafOf(facE.value) : undefined;
2387
- }
2388
- catch {
2389
- return undefined;
2390
- }
2391
- if (!self || self.kind !== "party")
2392
- return undefined;
2393
- if (!facilitator || facilitator.kind !== "party")
2394
- return undefined;
2395
- return { self: self.value, facilitator: facilitator.value };
2396
- }
2397
- /**
2398
- * Assert the relay-returned `preparedTransaction` for the x402-direct consent-create
2399
- * path encodes EXACTLY a single `SenderConsent` / `MerchantConsent` create, signed
2400
- * by the agent, naming {selfParty, facilitator} — and is NOT a value-moving choice,
2401
- * a second leg, a relay-substituted facilitator, or a foreign-party submission.
2402
- * Throws `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError`
2403
- * if the bytes are not a decodable PreparedTransaction. Call BEFORE signing. Fail-
2404
- * closed: anything not positively proven to be a self-submitted consent create among
2405
- * exactly {selfParty, facilitator} throws.
2406
- */
2407
- export function assertPreparedConsentCreateMatches(preparedTransactionB64, expect) {
2408
- const decoded = decodePrepared(preparedTransactionB64);
2409
- const selfLabel = expect.consent === "sender" ? "sender" : "merchant";
2410
- const expectedCreatedQualifiedName = expect.consent === "sender"
2411
- ? SENDER_CONSENT_CREATED_QUALIFIED_NAME
2412
- : MERCHANT_CONSENT_CREATED_QUALIFIED_NAME;
2413
- // (1) Shared CREATE-root invariant: EXACTLY one create of the expected consent
2414
- // template, NO exercise anywhere, every node recognized, no orphan/extra-leg node.
2415
- const { create, rootNodeId } = assertSingleAllowedRootCreate(decoded, expectedCreatedQualifiedName);
2416
- // (2) Pin the SIGNED synchronizer + timing metadata (relay-chosen validity
2417
- // window). Synchronizer is a no-op unless the caller pins it; timing is always
2418
- // sanity-bounded.
2419
- assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
2420
- assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2421
- // (3) act_as / signatory = own party. The consent's sole signatory is the signing
2422
- // party; act_as is the authoritative submitter, so act_as == selfParty proves the
2423
- // agent is creating its OWN consent (never a third party's authority).
2424
- assertActAsIsSender(decoded.actAs, expect.selfParty);
2425
- // (4) The created consent's principals at their declaration positions: [0] the
2426
- // self-party (sender/merchant) MUST equal the agent, [1] facilitator MUST equal
2427
- // caller intent. This pins WHO the consent binds — a relay-substituted facilitator
2428
- // (who could later mint a both-party consent and settle the agent's allocations)
2429
- // or a self-party other than the agent is rejected here even before the backstop.
2430
- if (create.argument === undefined) {
2431
- throw new PreparedTransferMismatchError(`relay-prepared consent create carries no decodable argument — refusing to sign ` +
2432
- `(cannot verify the consent's {${selfLabel}, facilitator})`);
2433
- }
2434
- const principals = readConsentCreatePrincipals(create.argument, selfLabel);
2435
- if (principals === undefined) {
2436
- throw new PreparedTransferMismatchError(`relay-prepared consent create argument is not the {${selfLabel}, facilitator} record ` +
2437
- `shape at positions [0],[1] — refusing to sign`);
2438
- }
2439
- if (principals.self !== expect.selfParty) {
2440
- throw new PreparedTransferMismatchError(`relay-prepared consent ${selfLabel} is ${JSON.stringify(principals.self)} — expected the ` +
2441
- `agent ${JSON.stringify(expect.selfParty)} — refusing to sign`);
2442
- }
2443
- if (principals.facilitator !== expect.facilitator) {
2444
- throw new PreparedTransferMismatchError(`relay-prepared consent facilitator is ${JSON.stringify(principals.facilitator)} — expected ` +
2445
- `${JSON.stringify(expect.facilitator)} — refusing to sign (a relay-substituted facilitator ` +
2446
- `could later mint a both-party consent and settle the agent's allocations)`);
2447
- }
2448
- // (5) Foreign-party backstop over the WHOLE signed message. The honest consent
2449
- // create introduces ONLY {selfParty, facilitator} (signatory + observer); the
2450
- // create root has no exercise, so all its party leaves are scanned under
2451
- // `elsewhere`. ANY other party anywhere is a smuggled redirect. Both allowed
2452
- // parties are CALLER INTENT; zero admin/dso slots (a consent has no instrument
2453
- // admin), so a relay-supplied value cannot be aliased in.
2454
- const allowed = new Set([expect.selfParty, expect.facilitator]);
2455
- const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2456
- assertNoForeignParties(leaves, allowed, undefined /* no admin/dso exemption */, 0 /* zero allowed admin slots */, false /* untrusted */);
2457
- }
2458
1816
  /** Thrown when a supplied `recomputeHash` cannot faithfully encode the bytes. */
2459
1817
  export class PreparedHashUnavailableError extends Error {
2460
1818
  constructor(message) {