@ftptech/canton-agent-wallet 0.1.15 → 0.1.16

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.
@@ -161,8 +161,12 @@ function decodeMessage(buf) {
161
161
  else if (wire === WIRE_64) {
162
162
  if (pos + 8 > buf.length)
163
163
  throw new PreparedDecodeError("64-bit field overruns buffer");
164
+ // Capture the 8 little-endian bytes: Daml `Value.timestamp` (Time µs) is a
165
+ // protobuf SFIXED64 (wire type 1), so a deadline/expiry leaf lives HERE, not
166
+ // as a varint. (Previously these bytes were discarded, which is why the
167
+ // allocation deadline read back as absent on the live wire.)
168
+ out.push({ field, wire, fixed64Bytes: buf.subarray(pos, pos + 8) });
164
169
  pos += 8;
165
- out.push({ field, wire });
166
170
  }
167
171
  else if (wire === WIRE_32) {
168
172
  if (pos + 4 > buf.length)
@@ -227,6 +231,53 @@ function varintFields(fields, field) {
227
231
  function countVarintField(fields, field) {
228
232
  return varintFields(fields, field).length;
229
233
  }
234
+ /** All WIRE_64 (fixed64) occurrences of a field number, in order. */
235
+ function fixed64Fields(fields, field) {
236
+ return fields.filter((f) => f.field === field && f.wire === WIRE_64 && f.fixed64Bytes !== undefined);
237
+ }
238
+ /** Number of WIRE_64 occurrences of a field (duplicate-oneof detection for the
239
+ * fixed64 `Value.timestamp` member, the analogue of `countVarintField`). */
240
+ function countFixed64Field(fields, field) {
241
+ return fixed64Fields(fields, field).length;
242
+ }
243
+ /**
244
+ * Decode an 8-byte little-endian protobuf fixed64/sfixed64 as a BigInt — full
245
+ * 64-bit precision, no float loss. Daml `Value.timestamp` (`Time`, µs since
246
+ * epoch) is wire type 1 (SFIXED64), little-endian per the protobuf spec.
247
+ * Timestamps are non-negative in practice (Canton Time is post-epoch), so we
248
+ * read the value as UNSIGNED; a "negative" (high-bit) timestamp would be an
249
+ * absurd far-future µs count that the past-/ordering checks reject anyway.
250
+ */
251
+ function fixed64ToBigInt(bytes) {
252
+ if (bytes.length !== 8) {
253
+ throw new PreparedDecodeError(`fixed64 field has ${bytes.length} bytes, expected exactly 8 — refusing to sign`);
254
+ }
255
+ let value = 0n;
256
+ for (let i = 7; i >= 0; i--) {
257
+ value = (value << 8n) | BigInt(bytes[i]);
258
+ }
259
+ return value;
260
+ }
261
+ /**
262
+ * Strict accessor for a NON-REPEATED fixed64 field (Daml `Time`/`Value.timestamp`):
263
+ * returns its single value as a BigInt, `undefined` if absent, and THROWS if it
264
+ * occurs more than once. Same fail-closed rationale as `varintFieldUnique`: the
265
+ * wire format is last-occurrence-wins for a non-repeated scalar, so a duplicate
266
+ * (decoy-first/real-second deadline) would let our read diverge from the
267
+ * participant's. There is no legitimate reason for a deadline leaf to appear
268
+ * twice within the same `Value`.
269
+ */
270
+ function fixed64FieldUnique(fields, field, what) {
271
+ const all = fixed64Fields(fields, field);
272
+ if (all.length > 1) {
273
+ throw new PreparedDecodeError(`non-repeated fixed64 field ${field} (${what}) appears ${all.length} times — ` +
274
+ `ambiguous encoding (last-occurrence-wins on the participant), refusing to sign`);
275
+ }
276
+ const only = all[0];
277
+ if (only === undefined)
278
+ return undefined;
279
+ return fixed64ToBigInt(only.fixed64Bytes);
280
+ }
230
281
  /**
231
282
  * Re-decode a raw varint byte-sequence as a BigInt — full int64 precision, no
232
283
  * `2**shift` float loss. proto3 int64 is up to 10 bytes (64 data bits). We cap
@@ -280,7 +331,7 @@ const V_UNIT = 1; // Value.unit (Daml `()`; google.protobuf.Empty). Non-party le
280
331
  const V_BOOL = 2; // Value.bool. Varint. Non-party leaf.
281
332
  const V_INT64 = 3; // Value.int64 (Daml `Int`, e.g. TransferCommand.nonce). Varint.
282
333
  const V_DATE = 4; // Value.date (Daml `Date`; days since epoch). Varint. Non-party leaf.
283
- const V_TIMESTAMP = 5; // Value.timestamp (Daml `Time`, e.g. expiresAt). Varint µs.
334
+ const V_TIMESTAMP = 5; // Value.timestamp (Daml `Time`, e.g. expiresAt). SFIXED64 µs (wire type 1, NOT varint).
284
335
  const V_NUMERIC = 6;
285
336
  const V_PARTY = 7;
286
337
  const V_TEXT = 8;
@@ -387,11 +438,18 @@ function assertSingleValueMember(value) {
387
438
  { tag: V_GEN_MAP, what: "Value.gen_map" },
388
439
  { tag: V_VARIANT, what: "Value.variant" },
389
440
  ];
390
- // Varint oneof members (Daml Int / Time). Counted separately because they use
391
- // wire type 0, not length-delimited; a duplicate int64 (decoy nonce first,
392
- // real nonce second) must be caught with the SAME fail-closed rigor.
441
+ // Varint oneof members (Daml `Int`). Counted separately because they use wire
442
+ // type 0, not length-delimited; a duplicate int64 (decoy nonce first, real
443
+ // nonce second) must be caught with the SAME fail-closed rigor.
393
444
  const varintMembers = [
394
445
  { tag: V_INT64, what: "Value.int64" },
446
+ ];
447
+ // Fixed64 oneof members (Daml `Time`/`Value.timestamp`). These use wire type 1
448
+ // (SFIXED64), NOT varint — so a duplicate deadline (decoy-first/real-second)
449
+ // would be INVISIBLE to the varint counter and could let our read diverge from
450
+ // the participant's. Count them with their own wire-type-correct accessor so the
451
+ // single-member + duplicate guards cover the timestamp leaf too.
452
+ const fixed64Members = [
395
453
  { tag: V_TIMESTAMP, what: "Value.timestamp" },
396
454
  ];
397
455
  let present = 0;
@@ -413,6 +471,15 @@ function assertSingleValueMember(value) {
413
471
  if (count === 1)
414
472
  present++;
415
473
  }
474
+ for (const m of fixed64Members) {
475
+ const count = countFixed64Field(v, m.tag);
476
+ if (count > 1) {
477
+ throw new PreparedDecodeError(`Value oneof member ${m.what} set ${count} times — ambiguous encoding ` +
478
+ `(last-occurrence-wins on the participant), refusing to sign`);
479
+ }
480
+ if (count === 1)
481
+ present++;
482
+ }
416
483
  if (present > 1) {
417
484
  throw new PreparedDecodeError("Value sets more than one oneof member — ambiguous encoding, refusing to sign");
418
485
  }
@@ -454,10 +521,16 @@ function leafOf(value) {
454
521
  // signed read (a negative nonce still decodes negative for the sanity check).
455
522
  if (int64 !== undefined)
456
523
  return { kind: "int64", value: zigzagDecodeInt64(int64).toString() };
457
- // Timestamp µs are always non-negative in practice; keep the raw (unsigned)
458
- // value Canton Time is post-epoch, so a "negative" timestamp would be a
459
- // pre-1970 microsecond count we'd reject on the past-expiry check anyway.
460
- const timestamp = varintFieldUnique(v, V_TIMESTAMP, "Value.timestamp");
524
+ // Daml `Value.timestamp` (`Time`, µs since epoch, e.g. settlement.allocateBefore/
525
+ // settleBefore) is a protobuf SFIXED64 wire type 1, little-endian 8 bytes
526
+ // NOT a varint. The earlier code read it via the varint accessor, so on the real
527
+ // wire (where the participant emits `field 5, wire 1`) the timestamp read back as
528
+ // ABSENT — the same fixture-vs-reality trap as the zigzag-nonce / Optional-DSO
529
+ // bugs: a varint-encoded fixture passed while the live fixed64 wire failed,
530
+ // false-tripping the deadline-fail-closed check on a legitimate allocation. Read
531
+ // it as a fixed64 so the value equals the participant's read. Timestamps are
532
+ // non-negative in practice; the unsigned value is exact (BigInt).
533
+ const timestamp = fixed64FieldUnique(v, V_TIMESTAMP, "Value.timestamp");
461
534
  if (timestamp !== undefined)
462
535
  return { kind: "timestamp", value: timestamp.toString() };
463
536
  return undefined;
@@ -696,6 +769,7 @@ const EX_RESULT = 13;
696
769
  const EX_CHOICE_OBSERVERS = 14;
697
770
  const EX_KEY = 15; // Exercise.key : optional GlobalKeyWithMaintainers
698
771
  // interactive.transaction.v1.Create field numbers.
772
+ const CREATE_TEMPLATE_ID = 4; // Create.template_id : Identifier (same shape as Exercise.template_id).
699
773
  const CREATE_ARGUMENT = 5;
700
774
  const CREATE_SIGNATORIES = 6;
701
775
  const CREATE_STAKEHOLDERS = 7;
@@ -767,7 +841,7 @@ function identifierQualifiedName(idBytes) {
767
841
  catch {
768
842
  return undefined;
769
843
  }
770
- if (flat.length === 0 || flat.includes(""))
844
+ if (flat.length === 0 || flat.includes("\u0000"))
771
845
  return undefined;
772
846
  const parts = flat.split(":");
773
847
  if (parts.length >= 3)
@@ -845,6 +919,7 @@ function decodeV1Node(v1Body) {
845
919
  if (create !== undefined) {
846
920
  const cFields = decodeMessage(create);
847
921
  const arg = lenFieldUnique(cFields, CREATE_ARGUMENT, "Create.argument");
922
+ const cTmpl = lenFieldUnique(cFields, CREATE_TEMPLATE_ID, "Create.template_id");
848
923
  const partyMeta = [
849
924
  ...stringFields(cFields, CREATE_SIGNATORIES),
850
925
  ...stringFields(cFields, CREATE_STAKEHOLDERS),
@@ -855,7 +930,19 @@ function decodeV1Node(v1Body) {
855
930
  const cKey = lenFieldUnique(cFields, CREATE_KEY, "Create.key");
856
931
  if (cKey !== undefined)
857
932
  collectGlobalKeyWithMaintainersParties(cKey, partyMeta, 0);
858
- return { kind: "create", children: [], values: arg !== undefined ? [arg] : [], partyMeta };
933
+ return {
934
+ kind: "create",
935
+ // Capture the created template + argument so an arm can pin a NUMERIC
936
+ // (non-party) field of a consequence create (e.g. X402Escrow.amount), which
937
+ // the party-leaf backstop cannot see.
938
+ create: {
939
+ templateQualifiedName: cTmpl !== undefined ? identifierQualifiedName(cTmpl) : undefined,
940
+ argument: arg,
941
+ },
942
+ children: [],
943
+ values: arg !== undefined ? [arg] : [],
944
+ partyMeta,
945
+ };
859
946
  }
860
947
  if (rollback !== undefined) {
861
948
  const rFields = decodeMessage(rollback);
@@ -1003,6 +1090,7 @@ export function decodePrepared(preparedTransactionB64) {
1003
1090
  recognizedVersion: true,
1004
1091
  kind: dv.kind,
1005
1092
  exercise: dv.exercise,
1093
+ create: dv.create,
1006
1094
  children: dv.children,
1007
1095
  values: dv.values,
1008
1096
  partyMeta: dv.partyMeta,
@@ -1164,6 +1252,101 @@ allowedConsequenceChoices = []) {
1164
1252
  }
1165
1253
  return { exercise: rootNode.exercise, rootNodeId: rootId };
1166
1254
  }
1255
+ /**
1256
+ * Create-root analog of `assertSingleAllowedRootExercise`: assert the prepared
1257
+ * transaction is EXACTLY a single top-level CREATE of the allowed template, with
1258
+ * NO exercise anywhere (a value-moving choice is never a legitimate consequence of
1259
+ * a bare consent create), EVERY node recognized, exactly ONE root that IS that
1260
+ * create, and no orphan/extra-leg node.
1261
+ *
1262
+ * Used by the x402-direct onboarding arms (`createSenderConsent` /
1263
+ * `createMerchantConsent`): the SenderConsent / MerchantConsent are created by the
1264
+ * party SIGNING ITS OWN create (signatory = that party), so the prepared tx is a
1265
+ * single CreateCommand → one CREATE root node. Creating a standing consent MOVES NO
1266
+ * FUNDS; the ONLY parties it introduces are the signing party and the facilitator
1267
+ * (observer). A relay-injected EXERCISE (an Allocation_ExecuteTransfer /
1268
+ * TransferFactory_Transfer / CreateTransferCommand drain) or a second create/root is
1269
+ * refused. Fail-closed in every case.
1270
+ */
1271
+ function assertSingleAllowedRootCreate(decoded,
1272
+ /** Caller-intent `module:entity` of the created template (e.g.
1273
+ * "X402Direct:SenderConsent"). Pinned against the create's decoded template when
1274
+ * present; a create carrying no decodable template is tolerated (normalized wire)
1275
+ * and identified by its principals at the call site. */
1276
+ expectedCreatedQualifiedName) {
1277
+ // (1) NO exercise of any choice anywhere. A consent create has no value-moving
1278
+ // consequence exercise; an exercise node is a relay-injected drain.
1279
+ if (decoded.exercises.length > 0) {
1280
+ throw new PreparedTransferMismatchError(`relay-prepared consent-create transaction contains ${decoded.exercises.length} ` +
1281
+ `exercise(s) ${decoded.exercises
1282
+ .map((e) => JSON.stringify(e.choiceId))
1283
+ .join(", ")} — expected a single bare create with no exercise — refusing to sign`);
1284
+ }
1285
+ // (2) Every node fully recognized (same fail-closed stance as the exercise arm).
1286
+ for (const node of decoded.nodes) {
1287
+ if (!node.recognizedVersion) {
1288
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) carried ` +
1289
+ `under an unrecognized node version — refusing to sign`);
1290
+ }
1291
+ if (node.kind === undefined) {
1292
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) with an ` +
1293
+ `unknown or ambiguous node type — refusing to sign`);
1294
+ }
1295
+ }
1296
+ // (3) Exactly one root, and it must be the allowed CREATE.
1297
+ if (decoded.roots.length !== 1) {
1298
+ throw new PreparedTransferMismatchError(`relay-prepared transaction has ${decoded.roots.length} root nodes — expected exactly one ` +
1299
+ `(${JSON.stringify(expectedCreatedQualifiedName)} create) — refusing to sign`);
1300
+ }
1301
+ const byId = new Map();
1302
+ for (const node of decoded.nodes) {
1303
+ if (byId.has(node.nodeId)) {
1304
+ throw new PreparedTransferMismatchError(`relay-prepared transaction has duplicate node id ${JSON.stringify(node.nodeId)} — ` +
1305
+ `refusing to sign (ambiguous node graph)`);
1306
+ }
1307
+ byId.set(node.nodeId, node);
1308
+ }
1309
+ const rootId = decoded.roots[0];
1310
+ const rootNode = byId.get(rootId);
1311
+ if (rootNode === undefined) {
1312
+ throw new PreparedTransferMismatchError(`relay-prepared transaction root id ${JSON.stringify(rootId)} does not match any node — ` +
1313
+ `refusing to sign`);
1314
+ }
1315
+ if (rootNode.kind !== "create" || rootNode.create === undefined) {
1316
+ throw new PreparedTransferMismatchError(`relay-prepared transaction root node is not a ${JSON.stringify(expectedCreatedQualifiedName)} ` +
1317
+ `create (got node type ${JSON.stringify(rootNode.kind ?? "unknown")}) — refusing to sign`);
1318
+ }
1319
+ // Pin the created template's module:entity when the node carries one (a normalized
1320
+ // wire may omit it; the caller then self-anchors on the create's principals).
1321
+ if (rootNode.create.templateQualifiedName !== undefined &&
1322
+ rootNode.create.templateQualifiedName !== expectedCreatedQualifiedName) {
1323
+ throw new PreparedTransferMismatchError(`relay-prepared create is of template ${JSON.stringify(rootNode.create.templateQualifiedName)} — ` +
1324
+ `expected ${JSON.stringify(expectedCreatedQualifiedName)} — refusing to sign`);
1325
+ }
1326
+ // (4) No orphans: every node reachable from the single root.
1327
+ const reachable = new Set();
1328
+ const stack = [rootId];
1329
+ while (stack.length > 0) {
1330
+ const id = stack.pop();
1331
+ if (reachable.has(id))
1332
+ continue;
1333
+ reachable.add(id);
1334
+ const node = byId.get(id);
1335
+ if (node === undefined) {
1336
+ throw new PreparedTransferMismatchError(`relay-prepared transaction references child node id ${JSON.stringify(id)} that does not ` +
1337
+ `exist — refusing to sign`);
1338
+ }
1339
+ for (const child of node.children)
1340
+ stack.push(child);
1341
+ }
1342
+ const orphans = decoded.nodes.filter((nd) => !reachable.has(nd.nodeId));
1343
+ if (orphans.length > 0) {
1344
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains ${orphans.length} node(s) not reachable from the root ` +
1345
+ `(ids ${orphans.map((o) => JSON.stringify(o.nodeId)).join(", ")}) — refusing to sign ` +
1346
+ `(possible tampered/compromised relay hiding an extra leg)`);
1347
+ }
1348
+ return { create: rootNode.create, rootNodeId: rootId };
1349
+ }
1167
1350
  /* ────────────────────────────────────────────────────────────────────────
1168
1351
  * Shared SIGNED-Metadata checks (synchronizer + timing). Every field below is
1169
1352
  * in the proto's "Metadata information that needs to be signed" block, so the
@@ -1174,6 +1357,35 @@ allowedConsequenceChoices = []) {
1174
1357
  * ──────────────────────────────────────────────────────────────────────── */
1175
1358
  /** Pin Metadata.synchronizer_id to caller intent when the caller supplies one.
1176
1359
  * Fail-closed: if pinned but absent/different, refuse. (No pin ⇒ unchanged.) */
1360
+ /**
1361
+ * The version-serial suffix a participant deterministically appends to the
1362
+ * LOGICAL synchronizer id to form the PHYSICAL one it signs into
1363
+ * Metadata.synchronizer_id: `::<protocol-version>-<topology-serial>`, e.g. a
1364
+ * logical `global-domain::1220<fingerprint>` is signed as
1365
+ * `global-domain::1220<fingerprint>::35-2`. STRICT shape: two non-empty decimal
1366
+ * runs joined by a single hyphen. (Confirmed on a live TestNet prepared
1367
+ * AllocationFactory_Allocate: prepare REQUIRES the logical id — the participant
1368
+ * 400s `INVALID_FIELD synchronizer_id` on the physical form — yet the bytes it
1369
+ * returns carry the physical `::35-2` suffix, so an exact-equality pin against
1370
+ * the logical caller intent false-rejects a legitimate tx.) */
1371
+ const PHYSICAL_SYNCHRONIZER_SUFFIX = /^[0-9]+-[0-9]+$/;
1372
+ /**
1373
+ * Pin Metadata.synchronizer_id to caller intent. Fail-closed: a different domain
1374
+ * is refused. The caller threads the LOGICAL synchronizer id (`<name>::<namespace
1375
+ * fingerprint>`) — the value the participant accepts on prepare — but the SIGNED
1376
+ * bytes carry the PHYSICAL id (logical + `::<version>-<serial>`). We therefore
1377
+ * accept the signed id iff it is EXACTLY the expected logical id, OR the expected
1378
+ * id followed by a single `::<version>-<serial>` suffix of the strict
1379
+ * physical-synchronizer shape.
1380
+ *
1381
+ * This is NOT a weakening: the security-relevant identity is the logical id,
1382
+ * whose `1220…` is the domain's cryptographic NAMESPACE fingerprint — an attacker
1383
+ * cannot forge a different domain that shares it, so anything matching
1384
+ * `expected + "::<n>-<m>"` is provably the SAME domain (same namespace owner) at
1385
+ * a participant-chosen protocol-version/topology-serial. A genuinely different
1386
+ * domain (different name or fingerprint), a non-suffix extension, or a
1387
+ * garbage/empty suffix does NOT match and is still rejected.
1388
+ */
1177
1389
  function assertSynchronizerMatches(synchronizerId, expected) {
1178
1390
  if (expected === undefined)
1179
1391
  return; // caller did not pin — unchanged behaviour
@@ -1181,9 +1393,17 @@ function assertSynchronizerMatches(synchronizerId, expected) {
1181
1393
  throw new PreparedTransferMismatchError(`relay-prepared transaction has no synchronizer_id but caller intent pins ` +
1182
1394
  `${JSON.stringify(expected)} — refusing to sign`);
1183
1395
  }
1184
- if (synchronizerId !== expected) {
1396
+ let matches = synchronizerId === expected;
1397
+ if (!matches && synchronizerId.startsWith(expected + "::")) {
1398
+ // The remainder after the logical id MUST be exactly one physical-suffix
1399
+ // segment `<version>-<serial>` — no further `::` segments, no empty/garbage.
1400
+ const suffix = synchronizerId.slice(expected.length + 2);
1401
+ matches = PHYSICAL_SYNCHRONIZER_SUFFIX.test(suffix);
1402
+ }
1403
+ if (!matches) {
1185
1404
  throw new PreparedTransferMismatchError(`relay-prepared transaction synchronizer_id is ${JSON.stringify(synchronizerId)} — expected ` +
1186
- `${JSON.stringify(expected)} refusing to sign (relay-chosen synchronizer/domain)`);
1405
+ `${JSON.stringify(expected)} (optionally with a ::<version>-<serial> physical suffix)` +
1406
+ `refusing to sign (relay-chosen synchronizer/domain)`);
1187
1407
  }
1188
1408
  }
1189
1409
  /** A generous skew (24h) for sanity-bounding the SIGNED Metadata timing fields.
@@ -1565,6 +1785,17 @@ const TRANSFER_CONSEQUENCE_CHOICES = [
1565
1785
  * even though the numeric value is identical. Pure string math (no float) so
1566
1786
  * large amounts keep full precision. A non-numeric input is returned as-is, so
1567
1787
  * it still mismatches (fail-closed).
1788
+ *
1789
+ * UNIT-BY-SCHEME invariant: BOTH sides of every amount compare here are
1790
+ * on-ledger Daml **Decimals** — `t.amount` is decoded from the prepared tx's
1791
+ * `Value.numeric` (always a ledger Decimal) and `expect.amount` is the caller's
1792
+ * INTENDED ledger Decimal (the relay/agent path sources `opts.amount` as the
1793
+ * Decimal, never the x402 wire atomic value). `canonicalAmount` therefore only
1794
+ * pads Decimals; it must NEVER be fed an atomic integer (an atomic "1" would
1795
+ * canonicalize to "1.0000000000" and silently mis-compare against the ledger
1796
+ * "0.0000000001"). If a future caller ever sources `expect.amount` from an
1797
+ * atomic-scheme wire, it MUST first convert via
1798
+ * `wireAmountToLedgerDecimal(scheme, amount)` from @ftptech/x402-canton-core.
1568
1799
  */
1569
1800
  export function canonicalAmount(raw) {
1570
1801
  const m = String(raw).trim().match(/^(\d+)(?:\.(\d*))?$/);
@@ -1910,6 +2141,808 @@ export function assertPreparedAcceptMatches(preparedTransactionB64, expect) {
1910
2141
  // authority, never a third party's.
1911
2142
  assertActAsIsSender(decoded.actAs, expect.selfParty);
1912
2143
  }
2144
+ /* ════════════════════════════════════════════════════════════════════════
2145
+ * allocation-api (CIP-56 DVP) — verify-before-sign for the
2146
+ * `AllocationFactory_Allocate` exercise.
2147
+ *
2148
+ * THREAT MODEL — identical to the cip56 / v1 arms, for the Allocation path.
2149
+ * The agent asks the relay to PREPARE an `AllocationFactory_Allocate` (it LOCKS
2150
+ * the agent's input holdings into an Allocation naming the facilitator as
2151
+ * settlement.executor). A compromised relay could prepare a DIFFERENT
2152
+ * allocation — swap the receiver, inflate the amount, name an ATTACKER (not the
2153
+ * facilitator) as settlement.executor so the attacker can later steer
2154
+ * Allocation_ExecuteTransfer, or widen the deadline window — and hand back its
2155
+ * hash. Because the agent signs with its own key (the choice's sole controller
2156
+ * is allocation.transferLeg.sender = the payer), a blind signature would
2157
+ * authorize the attacker's allocation. We decode the prepared bytes with the
2158
+ * SAME structural rigor (Daml-LF POSITIONAL record reads via entryByDeclOrder,
2159
+ * fail-closed on duplicate/multi-member oneofs, parties matched BY TYPE not
2160
+ * text) and assert each money/escrow-critical leaf equals CALLER INTENT at its
2161
+ * declaration-order position.
2162
+ *
2163
+ * AllocationFactory_Allocate choiceArgument :: Record {
2164
+ * [0] expectedAdmin : Party, -- the instrument admin (DSO)
2165
+ * [1] allocation : AllocationSpecification,
2166
+ * [2] requestedAt : Time,
2167
+ * [3] inputHoldingCids : [ContractId Holding],
2168
+ * [4] extraArgs : ExtraArgs
2169
+ * }
2170
+ * AllocationSpecification :: Record {
2171
+ * [0] settlement : SettlementInfo {
2172
+ * [0] executor : Party, -- MUST == facilitatorParty (executor)
2173
+ * [1] settlementRef : Reference,
2174
+ * [2] requestedAt : Time,
2175
+ * [3] allocateBefore : Time, -- MUST be < settleBefore
2176
+ * [4] settleBefore : Time,
2177
+ * [5] meta : Metadata },
2178
+ * [1] transferLegId : Text,
2179
+ * [2] transferLeg : TransferLeg {
2180
+ * [0] sender : Party, -- the payer (sole controller; act_as)
2181
+ * [1] receiver : Party, -- MUST == merchant payTo from the 402
2182
+ * [2] amount : Numeric, -- MUST == the required amount
2183
+ * [3] instrumentId : InstrumentId { [0] admin : Party, [1] id : Text },
2184
+ * [4] meta : Metadata }
2185
+ * }
2186
+ *
2187
+ * FIELD-ORDER AUTHORITY (confirmed): the declared field ORDER above is CONFIRMED
2188
+ * against the canonical Splice DAML source — Daml-LF encodes records POSITIONALLY
2189
+ * by `with`-block field declaration order, so the canonical .daml IS the
2190
+ * authoritative ground truth for these positions:
2191
+ * - SettlementInfo, TransferLeg, AllocationSpecification, Reference:
2192
+ * token-standard/splice-api-token-allocation-v1/daml/Splice/Api/Token/AllocationV1.daml
2193
+ * - InstrumentId:
2194
+ * token-standard/splice-api-token-holding-v1/daml/Splice/Api/Token/HoldingV1.daml
2195
+ * - AllocationFactory_Allocate choice arg:
2196
+ * token-standard/splice-api-token-allocation-instruction-v1/daml/Splice/Api/Token/AllocationInstructionV1.daml
2197
+ * repo github.com/canton-network/splice @ commit
2198
+ * b1767c6e9aba37a278faadd50b17b3e11b153a3f (main, fetched 2026-06-23). Every
2199
+ * position pinned below matches that source verbatim (AllocationSpecification
2200
+ * has 3 fields with transferLegId at [1] between settlement[0] and
2201
+ * transferLeg[2]; SettlementInfo executor[0]/allocateBefore[3]/settleBefore[4];
2202
+ * TransferLeg sender[0]/receiver[1]/amount[2]/instrumentId[3];
2203
+ * InstrumentId admin[0]/id[1]; choice arg allocation at [1]).
2204
+ * Daml-LF binds records POSITIONALLY, so the positional reads below
2205
+ * (entryByDeclOrder) are the exact, fail-closed pin the cip56/v1 arms already
2206
+ * use; this is a STRUCTURAL pin by declaration order, not a best-effort substring
2207
+ * scan. Do NOT silently relax the positional reads to label-based ones to "make
2208
+ * it work" against a live shape — that reintroduces the label/position divergence
2209
+ * vector the cip56 arm documents. RESIDUAL: a live participant prepared-tx vector
2210
+ * remains a nice-to-have cross-check (DevNet infra-blocked: synchronizer topology
2211
+ * frozen since 2026-06-03) but is no longer blocking — the canonical source above
2212
+ * is the field-order authority.
2213
+ * ════════════════════════════════════════════════════════════════════════ */
2214
+ /** The allocate choice on an AllocationFactory (allocation-api / DVP). */
2215
+ const ALLOCATE_CHOICE = "AllocationFactory_Allocate";
2216
+ /** Choices the honest AllocationFactory_Allocate consequence subtree carries —
2217
+ * for Amulet the allocation creates an AmuletAllocation + LockedAmulet and
2218
+ * archives the consumed input Amulet(s). Permitted ONLY as reachable
2219
+ * consequences of the single Allocate root (never as a second root / orphan);
2220
+ * sender/receiver/amount/executor/admin stay pinned by extractAllocation and
2221
+ * the all-nodes foreign-party backstop still runs, so an injected redirect is
2222
+ * refused. Kept conservative (Archive only) like the strict pay paths; widen
2223
+ * ONLY against a confirmed live consequence subtree, never speculatively. */
2224
+ const ALLOCATE_CONSEQUENCE_CHOICES = ["Archive"];
2225
+ /**
2226
+ * Extract the allocation's money/escrow-critical leaves from the
2227
+ * `AllocationFactory_Allocate` choice argument, BY TYPE at each Daml
2228
+ * DECLARATION-ORDER POSITION (what the participant binds), NOT by label — with
2229
+ * the same label/position-divergence guard the cip56/v1 arms use. Fails closed
2230
+ * on any wrong-type / missing money-critical field.
2231
+ */
2232
+ export function extractAllocation(chosenValue) {
2233
+ const top = recordEntries(chosenValue);
2234
+ // choiceArgument: [0]expectedAdmin [1]allocation [2]requestedAt
2235
+ // [3]inputHoldingCids [4]extraArgs — read position 1 ONLY.
2236
+ const allocationVal = entryByDeclOrder(top, 1, "allocation")?.value;
2237
+ if (!allocationVal) {
2238
+ throw new PreparedDecodeError("could not locate allocation record in AllocationFactory_Allocate choice argument");
2239
+ }
2240
+ // AllocationSpecification: [0]settlement [1]transferLegId [2]transferLeg.
2241
+ const spec = recordEntries(allocationVal);
2242
+ const settlementVal = entryByDeclOrder(spec, 0, "settlement")?.value;
2243
+ const transferLegVal = entryByDeclOrder(spec, 2, "transferLeg")?.value;
2244
+ if (!settlementVal)
2245
+ throw new PreparedDecodeError("allocation.settlement missing");
2246
+ if (!transferLegVal)
2247
+ throw new PreparedDecodeError("allocation.transferLeg missing");
2248
+ // SettlementInfo: [0]executor [1]settlementRef [2]requestedAt
2249
+ // [3]allocateBefore [4]settleBefore [5]meta.
2250
+ const settlement = recordEntries(settlementVal);
2251
+ const executorE = entryByDeclOrder(settlement, 0, "executor");
2252
+ const allocateBeforeE = entryByDeclOrder(settlement, 3, "allocateBefore");
2253
+ const settleBeforeE = entryByDeclOrder(settlement, 4, "settleBefore");
2254
+ const executor = executorE ? leafOf(executorE.value) : undefined;
2255
+ const allocateBefore = allocateBeforeE ? leafOf(allocateBeforeE.value) : undefined;
2256
+ const settleBefore = settleBeforeE ? leafOf(settleBeforeE.value) : undefined;
2257
+ if (!executor || executor.kind !== "party") {
2258
+ throw new PreparedDecodeError("allocation.settlement.executor is not a party");
2259
+ }
2260
+ // TransferLeg: [0]sender [1]receiver [2]amount [3]instrumentId [4]meta.
2261
+ const leg = recordEntries(transferLegVal);
2262
+ const senderE = entryByDeclOrder(leg, 0, "sender");
2263
+ const receiverE = entryByDeclOrder(leg, 1, "receiver");
2264
+ const amountE = entryByDeclOrder(leg, 2, "amount");
2265
+ const instrE = entryByDeclOrder(leg, 3, "instrumentId");
2266
+ const sender = senderE ? leafOf(senderE.value) : undefined;
2267
+ const receiver = receiverE ? leafOf(receiverE.value) : undefined;
2268
+ const amount = amountE ? leafOf(amountE.value) : undefined;
2269
+ const instrumentVal = instrE?.value;
2270
+ if (!sender || sender.kind !== "party") {
2271
+ throw new PreparedDecodeError("allocation.transferLeg.sender is not a party");
2272
+ }
2273
+ if (!receiver || receiver.kind !== "party") {
2274
+ throw new PreparedDecodeError("allocation.transferLeg.receiver is not a party");
2275
+ }
2276
+ if (!amount || amount.kind !== "numeric") {
2277
+ throw new PreparedDecodeError("allocation.transferLeg.amount is not numeric");
2278
+ }
2279
+ if (!instrumentVal)
2280
+ throw new PreparedDecodeError("allocation.transferLeg.instrumentId missing");
2281
+ const instrument = readInstrument(instrumentVal);
2282
+ return {
2283
+ sender: sender.value,
2284
+ receiver: receiver.value,
2285
+ amount: amount.value,
2286
+ instrumentAdmin: instrument.admin,
2287
+ instrumentId: instrument.id,
2288
+ executor: executor.value,
2289
+ ...(allocateBefore && allocateBefore.kind === "timestamp"
2290
+ ? { allocateBefore: allocateBefore.value }
2291
+ : {}),
2292
+ ...(settleBefore && settleBefore.kind === "timestamp"
2293
+ ? { settleBefore: settleBefore.value }
2294
+ : {}),
2295
+ };
2296
+ }
2297
+ /**
2298
+ * Assert the relay-returned `preparedTransaction` encodes EXACTLY the
2299
+ * `AllocationFactory_Allocate` the agent intended. Throws
2300
+ * `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError` if
2301
+ * the bytes are not a decodable PreparedTransaction carrying a single allocate
2302
+ * exercise. Call BEFORE signing the hash. Fail-closed: anything not positively
2303
+ * proven to match the intent throws.
2304
+ */
2305
+ export function assertPreparedAllocationMatches(preparedTransactionB64, expect) {
2306
+ const decoded = decodePrepared(preparedTransactionB64);
2307
+ // Node-traversal invariant (shared with the cip56 / v1 arms): exactly one
2308
+ // allowed ROOT exercise & no other; EVERY node recognized; EXACTLY ONE root
2309
+ // that IS the single allowed AllocationFactory_Allocate exercise; no
2310
+ // orphan/extra-leg node. Honest Amulet consequence (Archive of the locked
2311
+ // input) whitelisted as a CONSEQUENCE only.
2312
+ const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, ALLOCATE_CHOICE, "allocation", ALLOCATE_CONSEQUENCE_CHOICES);
2313
+ const a = extractAllocation(ex.chosenValue);
2314
+ // OWN-PARTY SENDER PIN (hardening) — asserted FIRST, fail-CLOSED. The
2315
+ // AllocationFactory_Allocate controller is the sender, and the agent knows its
2316
+ // OWN party (wallet.party) independently of the relay. The extracted
2317
+ // transferLeg.sender MUST equal that own party. transferLeg.sender and
2318
+ // transferLeg.receiver are BOTH Party leaves at adjacent declaration-order
2319
+ // positions and the declared order is not yet confirmed against a live wire, so
2320
+ // a real-wire transposition of those two (or a wrong-position sender) could
2321
+ // otherwise slip past the type guard. Pinning the sender to the agent's own
2322
+ // party neutralizes that transposition risk REGARDLESS of field order — a swap
2323
+ // that puts any non-self party (e.g. the receiver/merchant) in the sender
2324
+ // position is refused here before anything else runs.
2325
+ if (a.sender !== expect.expectedSender) {
2326
+ throw new PreparedTransferMismatchError(`relay-prepared allocation transferLeg.sender (got ${JSON.stringify(a.sender)}) is not ` +
2327
+ `the agent's own party (${JSON.stringify(expect.expectedSender)}) — refusing to sign ` +
2328
+ `(possible sender/receiver transposition or wrong-position sender from a tampered/` +
2329
+ `compromised relay redirecting the locked funds)`);
2330
+ }
2331
+ const mismatches = [];
2332
+ if (a.sender !== expect.sender) {
2333
+ mismatches.push(`transferLeg.sender (got ${JSON.stringify(a.sender)}, intended ${JSON.stringify(expect.sender)})`);
2334
+ }
2335
+ if (a.receiver !== expect.receiver) {
2336
+ mismatches.push(`transferLeg.receiver (got ${JSON.stringify(a.receiver)}, intended ${JSON.stringify(expect.receiver)})`);
2337
+ }
2338
+ if (canonicalAmount(a.amount) !== canonicalAmount(expect.amount)) {
2339
+ mismatches.push(`transferLeg.amount (got ${JSON.stringify(a.amount)}, intended ${JSON.stringify(expect.amount)})`);
2340
+ }
2341
+ if (a.instrumentId !== expect.instrumentId) {
2342
+ mismatches.push(`transferLeg.instrumentId.id (got ${JSON.stringify(a.instrumentId)}, intended ${JSON.stringify(expect.instrumentId)})`);
2343
+ }
2344
+ // settlement.executor MUST equal the facilitator party (escrow-trust anchor).
2345
+ if (a.executor !== expect.executor) {
2346
+ mismatches.push(`settlement.executor (got ${JSON.stringify(a.executor)}, intended ${JSON.stringify(expect.executor)})`);
2347
+ }
2348
+ // Only pin the admin if the caller supplied an independently-trusted value.
2349
+ if (expect.instrumentAdmin !== undefined && a.instrumentAdmin !== expect.instrumentAdmin) {
2350
+ mismatches.push(`transferLeg.instrumentId.admin (got ${JSON.stringify(a.instrumentAdmin)}, intended ${JSON.stringify(expect.instrumentAdmin)})`);
2351
+ }
2352
+ if (mismatches.length > 0) {
2353
+ throw new PreparedTransferMismatchError(`relay-prepared allocation does not match intent: ${mismatches.join("; ")} — ` +
2354
+ `refusing to sign (possible tampered/compromised relay redirecting funds or executor)`);
2355
+ }
2356
+ // The admin/dso must never be aliased to a money/escrow role — otherwise a
2357
+ // relay could set the (unpinned) admin to the receiver/sender/executor and
2358
+ // have the backstop exempt that party. Reject up front.
2359
+ if (a.instrumentAdmin === expect.sender ||
2360
+ a.instrumentAdmin === expect.receiver ||
2361
+ a.instrumentAdmin === expect.executor) {
2362
+ throw new PreparedTransferMismatchError(`relay-prepared allocation instrumentId.admin ${JSON.stringify(a.instrumentAdmin)} equals a ` +
2363
+ `transfer/escrow party (sender/receiver/executor) — refusing to sign`);
2364
+ }
2365
+ // The executor must not be aliased to the sender or receiver — the executor is
2366
+ // the facilitator (a distinct party); collapsing it onto a money party would
2367
+ // defeat the escrow-trust separation.
2368
+ if (a.executor === expect.sender || a.executor === expect.receiver) {
2369
+ throw new PreparedTransferMismatchError(`relay-prepared allocation settlement.executor ${JSON.stringify(a.executor)} equals the ` +
2370
+ `sender or receiver — refusing to sign (escrow-trust separation broken)`);
2371
+ }
2372
+ // DEADLINE FAIL-CLOSED (hardening). Both allocateBefore AND settleBefore MUST
2373
+ // be present as Value.timestamp leaves. extractAllocation only populates these
2374
+ // when the leaf is actually a `timestamp`; a relay that OMITS a deadline leaf
2375
+ // or RE-TYPES it (e.g. a text/numeric/optional leaf at the settleBefore
2376
+ // position) makes the field read back undefined. Previously the ordering +
2377
+ // future-margin check was CONDITIONAL on both being present, so such a relay
2378
+ // could make the client skip the check entirely. Now a missing/wrong-type
2379
+ // deadline is itself a tamper signal: THROW (fail closed) rather than sign an
2380
+ // allocation with no enforceable settle window.
2381
+ if (a.allocateBefore === undefined || a.settleBefore === undefined) {
2382
+ throw new PreparedTransferMismatchError(`relay-prepared allocation is missing a timestamp deadline ` +
2383
+ `(allocateBefore present=${a.allocateBefore !== undefined}, ` +
2384
+ `settleBefore present=${a.settleBefore !== undefined}) — refusing to sign ` +
2385
+ `(a relay that omits or re-types a deadline leaf must not bypass the ` +
2386
+ `ordering + future-margin check)`);
2387
+ }
2388
+ // allocateBefore < settleBefore (deadline ordering). The spec REQUIRES
2389
+ // settleBefore strictly after allocateBefore; an inverted/equal window is a
2390
+ // tamper signal (it would make the allocation un-executable or immediately
2391
+ // reclaimable). Both are Value.timestamp µs since epoch.
2392
+ {
2393
+ let ab;
2394
+ let sb;
2395
+ try {
2396
+ ab = BigInt(a.allocateBefore);
2397
+ sb = BigInt(a.settleBefore);
2398
+ }
2399
+ catch {
2400
+ throw new PreparedTransferMismatchError(`relay-prepared allocation has a non-integer deadline — refusing to sign`);
2401
+ }
2402
+ if (!(ab < sb)) {
2403
+ throw new PreparedTransferMismatchError(`relay-prepared allocation has allocateBefore (${a.allocateBefore}) >= settleBefore ` +
2404
+ `(${a.settleBefore}) — refusing to sign (invalid/un-executable deadline window)`);
2405
+ }
2406
+ // settleBefore must be in the future — a lapsed window can never settle.
2407
+ const nowMs = expect.nowMs ?? Date.now();
2408
+ const settleMs = Number(sb / 1000n);
2409
+ if (Number.isFinite(settleMs) && settleMs <= nowMs) {
2410
+ throw new PreparedTransferMismatchError(`relay-prepared allocation settleBefore (${new Date(settleMs).toISOString()}) is in the ` +
2411
+ `past — refusing to sign (allocation could never be executed)`);
2412
+ }
2413
+ }
2414
+ // Pin WHICH template + WHICH contract the choice runs against, and the SIGNED
2415
+ // synchronizer + timing metadata. No-ops unless the caller supplies intent.
2416
+ assertTemplateMatches(ex, expect.templateQualifiedName);
2417
+ assertContractIdMatches(ex, expect.expectedContractId);
2418
+ assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
2419
+ assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2420
+ // Cross-check the authoritative submitter: the allocate's sole controller is
2421
+ // transferLeg.sender = the agent, so act_as must be exactly the agent.
2422
+ assertActAsIsSender(decoded.actAs, expect.sender);
2423
+ // Position-aware foreign-party backstop over the WHOLE signed message. Allowed
2424
+ // recipients are {sender, receiver, executor} — the executor (facilitator) is a
2425
+ // LEGITIMATE party of the allocation (it is settlement.executor / observer), so
2426
+ // it must be in the allowed set or the honest allocation's own executor leaf
2427
+ // would surface as foreign. The instrument admin (DSO) is allowed at its known
2428
+ // root positions (expectedAdmin + instrumentId.admin = 2), value-excluded
2429
+ // OUTSIDE its root position ONLY when pinned to an independently-trusted value.
2430
+ const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2431
+ assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver, expect.executor]), a.instrumentAdmin, 2 /* expectedAdmin + instrumentId.admin */, expect.instrumentAdmin !== undefined /* trusted iff caller pinned it */);
2432
+ }
2433
+ /* ════════════════════════════════════════════════════════════════════════
2434
+ * allocation-api reclaim — verify-before-sign for `Allocation_Withdraw`
2435
+ * (Track B "locked funds always recoverable").
2436
+ *
2437
+ * THREAT MODEL. The agent has an open `AmuletAllocation` (it locked its own
2438
+ * holdings into an Allocation for a DvP that never settled / it wants to abort)
2439
+ * and wants its funds BACK. The canonical recovery choice on
2440
+ * `Splice.Api.Token.AllocationV1` is `Allocation_Withdraw`:
2441
+ *
2442
+ * choice Allocation_Withdraw : Allocation_WithdrawResult
2443
+ * controller (view this).allocation.transferLeg.sender -- the SENDER ALONE
2444
+ * with extraArgs : ExtraArgs
2445
+ * -- returns Allocation_WithdrawResult { senderHoldingCids, meta }
2446
+ * -- funds return to the sender BY CONSTRUCTION (no attacker-controllable
2447
+ * destination is encodable in the choice argument).
2448
+ * Confirmed against canton-network/splice@main, interface
2449
+ * Splice.Api.Token.AllocationV1.
2450
+ *
2451
+ * Even though the funds return to the sender by the choice's own definition, the
2452
+ * agent must NOT blind-sign a relay-prepared `Allocation_Withdraw`: a compromised
2453
+ * relay could instead prepare (a) a DIFFERENT choice that DOES move value to the
2454
+ * attacker (e.g. `Allocation_ExecuteTransfer`, a `TransferFactory_Transfer`
2455
+ * drain), (b) a withdraw of a DIFFERENT allocation than the one the caller
2456
+ * intended, (c) a withdraw with an extra/sibling outbound transfer leg as a
2457
+ * consequence, or (d) the same choice submitted under a foreign party's
2458
+ * authority. We therefore apply the SAME fail-closed structural gate the accept
2459
+ * (claim) arm uses — accept is the closest analog (funds-IN, structural,
2460
+ * own-party) — PLUS a REQUIRED contract-id pin (the caller names exactly which
2461
+ * allocation to reclaim, closing the resolve→prepare TOCTOU) and the all-nodes
2462
+ * foreign-party backstop with the ONLY allowed party being the agent itself
2463
+ * (Allocation_Withdraw returns funds to the sender, so ANY other party anywhere
2464
+ * — an injected outbound leg, a different receiver, an executor-as-payee — is a
2465
+ * smuggled redirect and is rejected). Anything not positively proven to be a
2466
+ * single self-submitted withdraw of the caller's own allocation throws.
2467
+ * ════════════════════════════════════════════════════════════════════════ */
2468
+ /** The reclaim choice on a Splice AmuletAllocation (funds-back / Track B). */
2469
+ const WITHDRAW_CHOICE = "Allocation_Withdraw";
2470
+ /** Choices the honest `Allocation_Withdraw` consequence subtree carries: it
2471
+ * archives the consumed `AmuletAllocation` / `LockedAmulet` and recreates the
2472
+ * sender's Amulet(s). Kept conservative (Archive only) like the strict pay /
2473
+ * allocate paths — a value-MOVING choice is never whitelisted here, so an
2474
+ * injected outbound drain is refused. Widen ONLY against a confirmed live
2475
+ * consequence subtree, never speculatively; the foreign-party backstop is the
2476
+ * real guard against a smuggled outbound leg regardless. */
2477
+ // Honest Allocation_Withdraw consequences. With an expire-lock choice-context the
2478
+ // withdraw unlocks the held Amulet back to its owner (the sender) via
2479
+ // LockedAmulet_UnlockV2 (older ledgers: LockedAmulet_Unlock) and archives the
2480
+ // consumed AmuletAllocation/LockedAmulet. These are SAFE to whitelist because the
2481
+ // step-5 foreign-party backstop still runs over the WHOLE signed message with the
2482
+ // agent (sender) as the ONLY permitted party — the unlock can therefore only
2483
+ // return funds to the sender, never redirect them. A relay-injected outbound drain
2484
+ // (Allocation_ExecuteTransfer / TransferFactory_Transfer / a second root) is still
2485
+ // neither the root choice nor a whitelisted consequence, so it is refused.
2486
+ const WITHDRAW_CONSEQUENCE_CHOICES = [
2487
+ "Archive",
2488
+ "LockedAmulet_UnlockV2",
2489
+ "LockedAmulet_Unlock",
2490
+ ];
2491
+ /**
2492
+ * Assert the relay-returned `preparedTransaction` for the reclaim path encodes
2493
+ * EXACTLY a single `Allocation_Withdraw` exercise, on the caller's own
2494
+ * allocation cid, submitted by the agent — and is NOT a different (value-moving)
2495
+ * choice, a withdraw of a different contract, an outbound drain, or a
2496
+ * foreign-party submission. Throws `PreparedTransferMismatchError` on any
2497
+ * mismatch and `PreparedDecodeError` if the bytes are not a decodable
2498
+ * PreparedTransaction. Call BEFORE signing the hash. Fail-closed: anything not
2499
+ * positively proven to be a self-withdraw of the caller's own allocation throws.
2500
+ */
2501
+ export function assertPreparedAllocationWithdrawMatches(preparedTransactionB64, expect) {
2502
+ const decoded = decodePrepared(preparedTransactionB64);
2503
+ // (1) Shared node-traversal invariant: EXACTLY ONE root and it IS the
2504
+ // Allocation_Withdraw exercise; EVERY node recognized; no orphan/extra-leg
2505
+ // node. The honest withdraw's settlement consequences (Archive of the consumed
2506
+ // AmuletAllocation / LockedAmulet) are whitelisted as CONSEQUENCES only — still
2507
+ // bound to the single Withdraw root by the reachability check. A relay-injected
2508
+ // outbound drain (Allocation_ExecuteTransfer / TransferFactory_Transfer /
2509
+ // CreateTransferCommand / a second root) is refused: it is neither the root
2510
+ // choice nor a whitelisted consequence.
2511
+ const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, WITHDRAW_CHOICE, WITHDRAW_CHOICE, WITHDRAW_CONSEQUENCE_CHOICES);
2512
+ // (2) Target-contract pin (CALLER INTENT — REQUIRED). The exercised
2513
+ // Exercise.contract_id MUST equal the caller's allocationCid: this is the core
2514
+ // "withdraw exactly the allocation I asked for" guarantee and closes the
2515
+ // resolve→prepare TOCTOU (a relay resolving one allocation to us then preparing
2516
+ // the withdraw against another).
2517
+ assertContractIdMatches(ex, expect.allocationCid);
2518
+ // (3) SIGNED timing metadata sanity (relay-chosen validity window).
2519
+ assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2520
+ // (4) act_as / controller = own party. The submitter (the authoritative
2521
+ // act_as) must be exactly the agent. The Daml controller is
2522
+ // allocation.transferLeg.sender; since act_as is the authoritative submitter
2523
+ // and the choice's sole controller is the sender, pinning act_as == selfParty
2524
+ // proves the agent is the sender exercising its own reclaim.
2525
+ assertActAsIsSender(decoded.actAs, expect.selfParty);
2526
+ // (5) Foreign-party / no-outbound-leg backstop over the WHOLE signed message.
2527
+ // Allocation_Withdraw returns funds to the SENDER by construction, so the ONLY
2528
+ // party permitted anywhere — root chosen_value, consequence/sibling nodes, any
2529
+ // node's party metadata, Metadata.input_contracts — is the agent itself. ANY
2530
+ // other party (an injected outbound transfer leg, a different receiver, an
2531
+ // executor-as-payee, a foreign submitter leaf) is a smuggled redirect and is
2532
+ // rejected. No admin/dso exemption is threaded (unlike accept/allocate): zero
2533
+ // allowed admin slots, so a relay-supplied DSO cannot be aliased to an attacker
2534
+ // and smuggled in. Widen ONLY if a confirmed live consequence subtree proves an
2535
+ // honest non-self party must appear — never speculatively.
2536
+ const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2537
+ // Allowed parties: the sender (funds-back recipient) ALWAYS, plus — ONLY when
2538
+ // the caller pinned them (lock-expiry path) — the facilitator (the allocation
2539
+ // executor / lock holder, who is a stakeholder on the unlocked LockedAmulet and
2540
+ // on disclosed contracts), the DSO (AmuletRules signatory referenced by the
2541
+ // expire-lock context), and the RECEIVER (merchant). All are caller-intent, so a
2542
+ // relay cannot smuggle a foreign recipient by aliasing it to one of them. On the
2543
+ // no-lock (empty-context) path none is pinned and the set stays {sender} exactly.
2544
+ //
2545
+ // The receiver pin closes the "Design B" DIRECT gap: there the allocation's
2546
+ // receiver is the MERCHANT (not the facilitator, as in "Design A" escrow where
2547
+ // receiver == executor == facilitator), so the legitimate expire-lock
2548
+ // choice-context references the merchant party — which is none of
2549
+ // {self, facilitator, instrumentAdmin}, so the backstop would otherwise reject a
2550
+ // VALID withdraw. We add EXACTLY the caller-pinned receiver (never a wildcard),
2551
+ // so it remains fail-closed: an UNPINNED receiver is still rejected, and any
2552
+ // party other than the exact pinned receiver is still rejected. Design A omits it
2553
+ // (receiver == facilitator, already allowed) and behavior is unchanged.
2554
+ const allowedWithdraw = new Set([expect.selfParty]);
2555
+ if (expect.facilitator)
2556
+ allowedWithdraw.add(expect.facilitator);
2557
+ if (expect.receiver)
2558
+ allowedWithdraw.add(expect.receiver);
2559
+ 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 */);
2560
+ }
2561
+ /* ════════════════════════════════════════════════════════════════════════
2562
+ * x402-escrow "Design A" ACCEPT — verify-before-sign for
2563
+ * `X402EscrowOffer_Accept` (the sender captures its one-time authorization).
2564
+ *
2565
+ * THREAT MODEL. The facilitator created an `X402EscrowOffer` (signatory
2566
+ * facilitator, observer sender) and the SENDER — an external party whose key the
2567
+ * agent-wallet holds — must exercise `X402EscrowOffer_Accept` to durably capture
2568
+ * its authorization. The choice's sole controller is the sender; ACCEPTING MOVES
2569
+ * NO FUNDS — it only archives the offer and creates the `X402Escrow` whose
2570
+ * signatories are {facilitator, sender}. (Source of truth: daml/x402-escrow/daml/
2571
+ * X402Escrow.daml — template `X402EscrowOffer`, choice `X402EscrowOffer_Accept`,
2572
+ * no choice args, `create X402Escrow with {facilitator, sender, merchant, amount,
2573
+ * instrumentAdmin}`; X402Escrow `signatory facilitator, sender`.)
2574
+ *
2575
+ * Even though the accept moves no funds, the agent must NOT blind-sign a
2576
+ * relay-prepared `X402EscrowOffer_Accept`: a compromised relay could instead
2577
+ * prepare (a) a DIFFERENT, value-MOVING choice (an `Allocation_ExecuteTransfer` /
2578
+ * `TransferFactory_Transfer` / `ExternalPartyAmuletRules_CreateTransferCommand` /
2579
+ * `Allocation_Withdraw` drain), (b) an accept of a DIFFERENT offer contract than
2580
+ * the one the caller intended, (c) an extra/sibling outbound transfer leg as a
2581
+ * consequence (an injected Amulet/Holding create to a foreign party), or (d) the
2582
+ * same choice submitted under a foreign party's authority. We apply the SAME
2583
+ * fail-closed structural gate the withdraw/accept(claim) arms use — withdraw is
2584
+ * the closest analog (own-party-controlled, structural, no money-critical leaf in
2585
+ * the chosen value) — PLUS a REQUIRED offer contract-id pin (closing the
2586
+ * resolve→prepare TOCTOU) and a position-aware foreign-party backstop whose
2587
+ * ONLY allowed parties are the agent (sender) and the caller-pinned facilitator
2588
+ * (the two `X402Escrow` signatories), optionally extended with the caller-pinned
2589
+ * merchant + instrumentAdmin (recorded as reference fields on the created
2590
+ * `X402Escrow`). ANY other party anywhere — a relay-substituted facilitator that
2591
+ * could later settle the escrow to itself, an injected outbound transfer leg, a
2592
+ * foreign submitter leaf — is a tamper signal and is rejected.
2593
+ *
2594
+ * WHAT AN ATTACKER-TAMPERED ACCEPT TRIPS:
2595
+ * - choice swapped to a value-moving drain → root-choice pin (1)
2596
+ * - second/sibling outbound leg or extra root → single-root + no-orphan (1)
2597
+ * - accept of a different offer cid → contract-id pin (2)
2598
+ * - act_as a foreign party (or self+foreign) → act_as == sender pin (4)
2599
+ * - relay-substituted facilitator / smuggled → foreign-party backstop (5)
2600
+ * recipient party in the created escrow / a
2601
+ * consequence Amulet|Holding create
2602
+ * Anything not positively proven to be a single self-submitted accept of the
2603
+ * caller's own offer, creating an escrow only among the caller-intended parties,
2604
+ * throws. Fail-closed in every case.
2605
+ * ════════════════════════════════════════════════════════════════════════ */
2606
+ /** The accept choice on an `X402EscrowOffer` (Design A escrow setup). */
2607
+ const ESCROW_ACCEPT_CHOICE = "X402EscrowOffer_Accept";
2608
+ /** Choices the honest `X402EscrowOffer_Accept` consequence subtree carries: it
2609
+ * archives the consumed `X402EscrowOffer` and creates the `X402Escrow` (a Create
2610
+ * node, NOT an exercise). The only consequence EXERCISE is the Archive of the
2611
+ * offer. Kept conservative (Archive only) like the strict withdraw/allocate
2612
+ * paths — a value-MOVING choice is NEVER whitelisted here, so an injected
2613
+ * outbound drain (Allocation_ExecuteTransfer / TransferFactory_Transfer /
2614
+ * CreateTransferCommand) as a consequence is refused. Widen ONLY against a
2615
+ * confirmed live consequence subtree, never speculatively; the foreign-party
2616
+ * backstop is the real guard against a smuggled outbound leg regardless. */
2617
+ const ESCROW_ACCEPT_CONSEQUENCE_CHOICES = ["Archive"];
2618
+ /** The created template's `module:entity` qualified name for the `X402Escrow`
2619
+ * contract the accept's consequence Create produces (package-id dropped — it
2620
+ * changes across upgrades). Used to positively identify the escrow Create node
2621
+ * among the consequences when pinning its recorded `amount`. */
2622
+ const X402_ESCROW_CREATED_QUALIFIED_NAME = "X402Escrow:X402Escrow";
2623
+ /**
2624
+ * Extract the `amount` recorded on a created `X402Escrow` from its `Create.argument`
2625
+ * record, BY ITS DAML DECLARATION-ORDER POSITION (with the same label/position
2626
+ * divergence guard the money fields use).
2627
+ *
2628
+ * X402Escrow create-args (X402Escrow.daml, Daml declaration order):
2629
+ * [0] facilitator : Party
2630
+ * [1] sender : Party
2631
+ * [2] merchant : Party
2632
+ * [3] amount : Decimal ← the load-bearing settle term
2633
+ * [4] instrumentAdmin : Party
2634
+ *
2635
+ * WHY THIS MATTERS (the amount-swap bypass the party backstop cannot see). The
2636
+ * accept choice has NO arguments; the escrow's terms live in this CONSEQUENCE
2637
+ * Create. The foreign-party backstop only inspects PARTY leaves, so a swapped
2638
+ * `amount` (a Numeric) is INVISIBLE to it. Yet the escrow's `amount` is exactly
2639
+ * what the facilitator's later `X402Escrow_Settle` pins the live allocation
2640
+ * against (`assertMsg "allocation amount mismatch" (leg.amount == amount)` in
2641
+ * X402Escrow.daml). A malicious/buggy facilitator that authored the offer with an
2642
+ * inflated `amount` (sender shown a small price) would, on a blind-signed accept,
2643
+ * obtain the sender's durable authorization for an escrow that settles the larger
2644
+ * amount. So when the caller pins `amount`, the wallet MUST read this Numeric at
2645
+ * its real position and require exact (canonical) equality — fail closed.
2646
+ *
2647
+ * Returns the amount as a decimal string. Throws `PreparedDecodeError` if the
2648
+ * argument is not the escrow create record shape (position 3 not a Numeric, or a
2649
+ * label/position divergence) — a tamper signal in itself.
2650
+ */
2651
+ function extractEscrowCreateAmount(argument) {
2652
+ const entries = recordEntries(argument);
2653
+ const amountE = entryByDeclOrder(entries, 3, "amount");
2654
+ const amount = amountE ? leafOf(amountE.value) : undefined;
2655
+ if (!amount || amount.kind !== "numeric") {
2656
+ throw new PreparedDecodeError("X402Escrow.amount is not numeric");
2657
+ }
2658
+ return amount.value;
2659
+ }
2660
+ /**
2661
+ * Read the {facilitator, sender} parties of an `X402Escrow` create-args record at
2662
+ * their declaration positions ([0] facilitator, [1] sender), for self-anchored
2663
+ * identification of the escrow Create among consequence creates when the create
2664
+ * carries no decodable template id. Returns undefined if the record is not the
2665
+ * escrow shape at those positions (so a non-escrow consequence create is simply
2666
+ * skipped, not mis-pinned).
2667
+ */
2668
+ function readEscrowCreatePrincipals(argument) {
2669
+ let entries;
2670
+ try {
2671
+ entries = recordEntries(argument);
2672
+ }
2673
+ catch {
2674
+ return undefined;
2675
+ }
2676
+ let facilitator;
2677
+ let sender;
2678
+ try {
2679
+ const facE = entryByDeclOrder(entries, 0, "facilitator");
2680
+ const sndE = entryByDeclOrder(entries, 1, "sender");
2681
+ facilitator = facE ? leafOf(facE.value) : undefined;
2682
+ sender = sndE ? leafOf(sndE.value) : undefined;
2683
+ }
2684
+ catch {
2685
+ return undefined;
2686
+ }
2687
+ if (!facilitator || facilitator.kind !== "party")
2688
+ return undefined;
2689
+ if (!sender || sender.kind !== "party")
2690
+ return undefined;
2691
+ return { facilitator: facilitator.value, sender: sender.value };
2692
+ }
2693
+ /**
2694
+ * Pin the `amount` recorded on the created `X402Escrow` to caller intent, when the
2695
+ * caller supplies one. Locates the single escrow Create node among the (already
2696
+ * reachability-checked) consequence nodes — by its created-template qualified name
2697
+ * when present, else self-anchored on the create's {facilitator, sender} principals
2698
+ * matching the caller-pinned facilitator + selfParty — then requires its Numeric
2699
+ * `amount` to canonically equal `expect.amount`. Fail closed: if the caller pins an
2700
+ * amount and NO matching escrow create is found (or its amount differs / is not a
2701
+ * Numeric), refuse to sign. No pin ⇒ unchanged behaviour.
2702
+ */
2703
+ function assertEscrowCreateAmountMatches(decoded, expectedAmount, facilitator, selfParty) {
2704
+ if (expectedAmount === undefined)
2705
+ return; // caller did not pin — unchanged behaviour
2706
+ // Candidate escrow Create nodes: prefer an exact created-template match; fall
2707
+ // back to self-anchored principals (facilitator + sender) when the create
2708
+ // carries no decodable template (e.g. a normalized wire). Both anchors are
2709
+ // CALLER INTENT / already-trusted — never a relay-widened allowlist.
2710
+ const escrowCreates = decoded.nodes.filter((n) => {
2711
+ if (n.kind !== "create" || n.create === undefined || n.create.argument === undefined) {
2712
+ return false;
2713
+ }
2714
+ if (n.create.templateQualifiedName === X402_ESCROW_CREATED_QUALIFIED_NAME)
2715
+ return true;
2716
+ if (n.create.templateQualifiedName !== undefined)
2717
+ return false; // a DIFFERENT named template
2718
+ const principals = readEscrowCreatePrincipals(n.create.argument);
2719
+ return (principals !== undefined &&
2720
+ principals.facilitator === facilitator &&
2721
+ principals.sender === selfParty);
2722
+ });
2723
+ if (escrowCreates.length === 0) {
2724
+ throw new PreparedTransferMismatchError(`relay-prepared escrow-accept does not create an X402Escrow with the caller's ` +
2725
+ `{facilitator, sender} — refusing to sign (cannot verify the escrow's recorded amount)`);
2726
+ }
2727
+ if (escrowCreates.length > 1) {
2728
+ throw new PreparedTransferMismatchError(`relay-prepared escrow-accept creates ${escrowCreates.length} X402Escrow contracts — ` +
2729
+ `expected exactly one — refusing to sign`);
2730
+ }
2731
+ const argument = escrowCreates[0].create?.argument;
2732
+ const onLedger = extractEscrowCreateAmount(argument);
2733
+ if (canonicalAmount(onLedger) !== canonicalAmount(expectedAmount)) {
2734
+ throw new PreparedTransferMismatchError(`relay-prepared escrow records amount ${JSON.stringify(onLedger)} — expected ` +
2735
+ `${JSON.stringify(expectedAmount)} — refusing to sign ` +
2736
+ `(a swapped escrow amount settles a different value than the sender intends)`);
2737
+ }
2738
+ }
2739
+ /**
2740
+ * Assert the relay-returned `preparedTransaction` for the escrow-accept path
2741
+ * encodes EXACTLY a single `X402EscrowOffer_Accept` exercise, on the caller's own
2742
+ * offer cid, submitted by the agent (sender) — and is NOT a different
2743
+ * (value-moving) choice, an accept of a different contract, an outbound drain, or
2744
+ * a foreign-party submission, and creates an `X402Escrow` only among the
2745
+ * caller-intended parties. Throws `PreparedTransferMismatchError` on any mismatch
2746
+ * and `PreparedDecodeError` if the bytes are not a decodable PreparedTransaction.
2747
+ * Call BEFORE signing the hash. Fail-closed: anything not positively proven to be
2748
+ * a self-submitted accept of the caller's own offer throws.
2749
+ */
2750
+ export function assertPreparedEscrowAcceptMatches(preparedTransactionB64, expect) {
2751
+ const decoded = decodePrepared(preparedTransactionB64);
2752
+ // (1) Shared node-traversal invariant: EXACTLY ONE root and it IS the
2753
+ // X402EscrowOffer_Accept exercise; EVERY node recognized; no orphan/extra-leg
2754
+ // node. The honest accept's consequences (Archive of the consumed
2755
+ // X402EscrowOffer + the Create of the X402Escrow — a Create node, not an
2756
+ // exercise) are bound to the single Accept root by the reachability check; the
2757
+ // only whitelisted consequence EXERCISE is Archive. A relay-injected outbound
2758
+ // drain (Allocation_ExecuteTransfer / TransferFactory_Transfer /
2759
+ // ExternalPartyAmuletRules_CreateTransferCommand / Allocation_Withdraw / a
2760
+ // second root) is refused: it is neither the root choice nor a whitelisted
2761
+ // consequence.
2762
+ const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, ESCROW_ACCEPT_CHOICE, ESCROW_ACCEPT_CHOICE, ESCROW_ACCEPT_CONSEQUENCE_CHOICES);
2763
+ // (2) Target-contract pin (CALLER INTENT — REQUIRED). The exercised
2764
+ // Exercise.contract_id MUST equal the caller's offerCid: the core "accept
2765
+ // exactly the offer I asked for" guarantee, closing the resolve→prepare TOCTOU
2766
+ // (a relay resolving one offer to us then preparing the accept against another).
2767
+ assertContractIdMatches(ex, expect.offerCid);
2768
+ // (3) Pin WHICH template the choice runs against + the SIGNED synchronizer +
2769
+ // timing metadata. Template/synchronizer are no-ops unless the caller pins them;
2770
+ // timing is always sanity-bounded (relay-chosen validity window).
2771
+ assertTemplateMatches(ex, expect.templateQualifiedName);
2772
+ assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
2773
+ assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2774
+ // (4) act_as / controller = own party (the sender). The submitter (the
2775
+ // authoritative act_as) must be exactly the agent. The Daml controller is the
2776
+ // offer's `sender`; since act_as is the authoritative submitter and the choice's
2777
+ // sole controller is the sender, pinning act_as == selfParty proves the agent is
2778
+ // the sender exercising its own accept — never a third party's authority.
2779
+ assertActAsIsSender(decoded.actAs, expect.selfParty);
2780
+ // (5) Foreign-party / no-outbound-leg backstop over the WHOLE signed message.
2781
+ // The honest accept creates an `X402Escrow` signed by {facilitator, sender} and
2782
+ // recording merchant + instrumentAdmin as reference fields; the consumed
2783
+ // `X402EscrowOffer` (signatory facilitator, observer sender) is archived and
2784
+ // appears in Metadata.input_contracts. So the ONLY parties that may appear
2785
+ // anywhere are the agent (sender), the caller-pinned facilitator, and — when the
2786
+ // honest escrow names them as distinct parties — the caller-pinned merchant +
2787
+ // instrumentAdmin. EVERY allowed party is CALLER INTENT (selfParty/facilitator
2788
+ // are required; merchant/instrumentAdmin are pinned when supplied) — none is
2789
+ // taken from the relay. ANY other party anywhere (a relay-substituted
2790
+ // facilitator that could later settle to itself, an injected outbound transfer
2791
+ // leg creating an Amulet/Holding for a foreign payee, a foreign submitter leaf)
2792
+ // is a smuggled redirect and is rejected. No admin/dso *root-position* exemption
2793
+ // is threaded (the instrumentAdmin here is a plain reference field of the created
2794
+ // escrow, allowed by exact-equality membership when pinned, not by a positional
2795
+ // admin budget): zero allowed admin slots, so a relay-supplied value cannot be
2796
+ // aliased to an attacker and smuggled in.
2797
+ const allowed = new Set([expect.selfParty, expect.facilitator]);
2798
+ if (expect.merchant !== undefined)
2799
+ allowed.add(expect.merchant);
2800
+ if (expect.instrumentAdmin !== undefined)
2801
+ allowed.add(expect.instrumentAdmin);
2802
+ const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2803
+ assertNoForeignParties(leaves, allowed, undefined /* no positional admin/dso exemption */, 0 /* zero allowed admin slots */, false /* untrusted */);
2804
+ // (6) NUMERIC escrow-term pin the party backstop cannot see. The accept choice
2805
+ // has no arguments; the escrow's terms live in the consequence Create of the
2806
+ // X402Escrow. `amount` is a Daml Decimal (a Numeric leaf), invisible to the
2807
+ // foreign-party backstop above — yet it is exactly what the facilitator's later
2808
+ // `X402Escrow_Settle` pins the live allocation against. When the caller pins an
2809
+ // amount we read the created escrow's recorded `amount` at its real declaration
2810
+ // position and require canonical equality; fail closed otherwise. This closes
2811
+ // the amount-swap trust-boundary gap: a malicious/buggy facilitator authoring
2812
+ // the offer with an inflated amount (sender shown a small price) can no longer
2813
+ // get the sender to blind-authorize an escrow that settles the larger value.
2814
+ assertEscrowCreateAmountMatches(decoded, expect.amount, expect.facilitator, expect.selfParty);
2815
+ }
2816
+ /* ════════════════════════════════════════════════════════════════════════
2817
+ * x402-direct (Design B "2-tx DIRECT") CONSENT CREATE — verify-before-sign for a
2818
+ * `SenderConsent` / `MerchantConsent` create (the party captures its one-time
2819
+ * standing authorization).
2820
+ *
2821
+ * THREAT MODEL. In the direct flow each party signs ONE standing consent ever — the
2822
+ * SENDER a `SenderConsent {sender, facilitator}`, the MERCHANT a `MerchantConsent
2823
+ * {merchant, facilitator}` (source of truth: daml/x402-direct/daml/X402Direct.daml;
2824
+ * each template's signatory is the named party, observer = facilitator). The party
2825
+ * (an external party whose key the agent-wallet holds) creates it directly, so the
2826
+ * prepared tx is a single CreateCommand → one CREATE root. CREATING A STANDING
2827
+ * CONSENT MOVES NO FUNDS.
2828
+ *
2829
+ * Even so the agent must NOT blind-sign: a compromised relay could instead prepare
2830
+ * (a) a value-MOVING exercise (an `Allocation_ExecuteTransfer` /
2831
+ * `TransferFactory_Transfer` / `ExternalPartyAmuletRules_CreateTransferCommand` /
2832
+ * `Allocation_Withdraw` drain), (b) a consent naming a DIFFERENT facilitator (who
2833
+ * could later mint a both-party consent and settle the agent's allocations), (c) an
2834
+ * extra/sibling outbound leg, or (d) a foreign-party submission. We apply a
2835
+ * fail-closed CREATE-root gate (the create analog of the escrow-accept/withdraw
2836
+ * arms): EXACTLY one create of the expected template, NO exercise anywhere, act_as
2837
+ * == the signing party, the consent's [0] self-party == the agent AND [1]
2838
+ * facilitator == caller intent, and a foreign-party backstop whose ONLY allowed
2839
+ * parties are {selfParty, facilitator} (the consent's two parties). ANY other party
2840
+ * anywhere — a relay-substituted facilitator, an injected outbound leg, a foreign
2841
+ * submitter leaf — is rejected.
2842
+ *
2843
+ * WHAT AN ATTACKER-TAMPERED CONSENT CREATE TRIPS:
2844
+ * - swapped to a value-moving exercise → no-exercise + create-root pin
2845
+ * - second/sibling leg or extra root → single-root + no-orphan
2846
+ * - relay-substituted facilitator → [1] facilitator pin + backstop
2847
+ * - self-party not the agent → [0] self-party pin + act_as pin
2848
+ * - act_as a foreign party → act_as == selfParty pin
2849
+ * - any smuggled recipient party anywhere → foreign-party backstop
2850
+ * Anything not positively proven to be a single self-submitted create of the
2851
+ * caller's own consent among exactly {selfParty, facilitator} throws. Fail-closed.
2852
+ * ════════════════════════════════════════════════════════════════════════ */
2853
+ /** Created template `module:entity` names for the two standing consents. */
2854
+ const SENDER_CONSENT_CREATED_QUALIFIED_NAME = "X402Direct:SenderConsent";
2855
+ const MERCHANT_CONSENT_CREATED_QUALIFIED_NAME = "X402Direct:MerchantConsent";
2856
+ /** Read the {self, facilitator} parties of a consent create-args record at their
2857
+ * declaration positions ([0] self, [1] facilitator). Both SenderConsent and
2858
+ * MerchantConsent share this shape (the [0] label differs: sender vs merchant).
2859
+ * Returns undefined if the record is not the consent shape at those positions. */
2860
+ function readConsentCreatePrincipals(argument, selfLabel) {
2861
+ let entries;
2862
+ try {
2863
+ entries = recordEntries(argument);
2864
+ }
2865
+ catch {
2866
+ return undefined;
2867
+ }
2868
+ let self;
2869
+ let facilitator;
2870
+ try {
2871
+ const selfE = entryByDeclOrder(entries, 0, selfLabel);
2872
+ const facE = entryByDeclOrder(entries, 1, "facilitator");
2873
+ self = selfE ? leafOf(selfE.value) : undefined;
2874
+ facilitator = facE ? leafOf(facE.value) : undefined;
2875
+ }
2876
+ catch {
2877
+ return undefined;
2878
+ }
2879
+ if (!self || self.kind !== "party")
2880
+ return undefined;
2881
+ if (!facilitator || facilitator.kind !== "party")
2882
+ return undefined;
2883
+ return { self: self.value, facilitator: facilitator.value };
2884
+ }
2885
+ /**
2886
+ * Assert the relay-returned `preparedTransaction` for the x402-direct consent-create
2887
+ * path encodes EXACTLY a single `SenderConsent` / `MerchantConsent` create, signed
2888
+ * by the agent, naming {selfParty, facilitator} — and is NOT a value-moving choice,
2889
+ * a second leg, a relay-substituted facilitator, or a foreign-party submission.
2890
+ * Throws `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError`
2891
+ * if the bytes are not a decodable PreparedTransaction. Call BEFORE signing. Fail-
2892
+ * closed: anything not positively proven to be a self-submitted consent create among
2893
+ * exactly {selfParty, facilitator} throws.
2894
+ */
2895
+ export function assertPreparedConsentCreateMatches(preparedTransactionB64, expect) {
2896
+ const decoded = decodePrepared(preparedTransactionB64);
2897
+ const selfLabel = expect.consent === "sender" ? "sender" : "merchant";
2898
+ const expectedCreatedQualifiedName = expect.consent === "sender"
2899
+ ? SENDER_CONSENT_CREATED_QUALIFIED_NAME
2900
+ : MERCHANT_CONSENT_CREATED_QUALIFIED_NAME;
2901
+ // (1) Shared CREATE-root invariant: EXACTLY one create of the expected consent
2902
+ // template, NO exercise anywhere, every node recognized, no orphan/extra-leg node.
2903
+ const { create, rootNodeId } = assertSingleAllowedRootCreate(decoded, expectedCreatedQualifiedName);
2904
+ // (2) Pin the SIGNED synchronizer + timing metadata (relay-chosen validity
2905
+ // window). Synchronizer is a no-op unless the caller pins it; timing is always
2906
+ // sanity-bounded.
2907
+ assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
2908
+ assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2909
+ // (3) act_as / signatory = own party. The consent's sole signatory is the signing
2910
+ // party; act_as is the authoritative submitter, so act_as == selfParty proves the
2911
+ // agent is creating its OWN consent (never a third party's authority).
2912
+ assertActAsIsSender(decoded.actAs, expect.selfParty);
2913
+ // (4) The created consent's principals at their declaration positions: [0] the
2914
+ // self-party (sender/merchant) MUST equal the agent, [1] facilitator MUST equal
2915
+ // caller intent. This pins WHO the consent binds — a relay-substituted facilitator
2916
+ // (who could later mint a both-party consent and settle the agent's allocations)
2917
+ // or a self-party other than the agent is rejected here even before the backstop.
2918
+ if (create.argument === undefined) {
2919
+ throw new PreparedTransferMismatchError(`relay-prepared consent create carries no decodable argument — refusing to sign ` +
2920
+ `(cannot verify the consent's {${selfLabel}, facilitator})`);
2921
+ }
2922
+ const principals = readConsentCreatePrincipals(create.argument, selfLabel);
2923
+ if (principals === undefined) {
2924
+ throw new PreparedTransferMismatchError(`relay-prepared consent create argument is not the {${selfLabel}, facilitator} record ` +
2925
+ `shape at positions [0],[1] — refusing to sign`);
2926
+ }
2927
+ if (principals.self !== expect.selfParty) {
2928
+ throw new PreparedTransferMismatchError(`relay-prepared consent ${selfLabel} is ${JSON.stringify(principals.self)} — expected the ` +
2929
+ `agent ${JSON.stringify(expect.selfParty)} — refusing to sign`);
2930
+ }
2931
+ if (principals.facilitator !== expect.facilitator) {
2932
+ throw new PreparedTransferMismatchError(`relay-prepared consent facilitator is ${JSON.stringify(principals.facilitator)} — expected ` +
2933
+ `${JSON.stringify(expect.facilitator)} — refusing to sign (a relay-substituted facilitator ` +
2934
+ `could later mint a both-party consent and settle the agent's allocations)`);
2935
+ }
2936
+ // (5) Foreign-party backstop over the WHOLE signed message. The honest consent
2937
+ // create introduces ONLY {selfParty, facilitator} (signatory + observer); the
2938
+ // create root has no exercise, so all its party leaves are scanned under
2939
+ // `elsewhere`. ANY other party anywhere is a smuggled redirect. Both allowed
2940
+ // parties are CALLER INTENT; zero admin/dso slots (a consent has no instrument
2941
+ // admin), so a relay-supplied value cannot be aliased in.
2942
+ const allowed = new Set([expect.selfParty, expect.facilitator]);
2943
+ const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2944
+ assertNoForeignParties(leaves, allowed, undefined /* no admin/dso exemption */, 0 /* zero allowed admin slots */, false /* untrusted */);
2945
+ }
1913
2946
  /** Thrown when a supplied `recomputeHash` cannot faithfully encode the bytes. */
1914
2947
  export class PreparedHashUnavailableError extends Error {
1915
2948
  constructor(message) {