@ftptech/canton-agent-wallet 0.1.15 → 0.1.17
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.
- package/README.md +30 -2
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +11 -0
- package/dist/cli-args.js.map +1 -1
- package/dist/cli.js +122 -3
- package/dist/cli.js.map +1 -1
- package/dist/hosted-onboard.d.ts +88 -0
- package/dist/hosted-onboard.d.ts.map +1 -0
- package/dist/hosted-onboard.js +207 -0
- package/dist/hosted-onboard.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/onboard.d.ts +8 -0
- package/dist/onboard.d.ts.map +1 -1
- package/dist/onboard.js +13 -4
- package/dist/onboard.js.map +1 -1
- package/dist/pay.d.ts +27 -0
- package/dist/pay.d.ts.map +1 -1
- package/dist/pay.js +33 -12
- package/dist/pay.js.map +1 -1
- package/dist/relay-client.d.ts +96 -0
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +36 -0
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-signer.d.ts +15 -0
- package/dist/relay-signer.d.ts.map +1 -1
- package/dist/relay-signer.js +127 -1
- package/dist/relay-signer.js.map +1 -1
- package/dist/tx.d.ts +228 -0
- package/dist/tx.d.ts.map +1 -1
- package/dist/tx.js +416 -1
- package/dist/tx.js.map +1 -1
- package/dist/verify-prepared.d.ts +231 -0
- package/dist/verify-prepared.d.ts.map +1 -1
- package/dist/verify-prepared.js +1046 -13
- package/dist/verify-prepared.js.map +1 -1
- package/package.json +2 -2
package/dist/verify-prepared.js
CHANGED
|
@@ -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).
|
|
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
|
|
391
|
-
//
|
|
392
|
-
//
|
|
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
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
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 {
|
|
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
|
-
|
|
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)}
|
|
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) {
|