@ftptech/canton-agent-wallet 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,1991 +104,11 @@
104
104
  */
105
105
  import { createHash, createPublicKey, timingSafeEqual } from "node:crypto";
106
106
  import { decodeTopologyTransaction } from "@canton-network/core-tx-visualizer";
107
- /* ────────────────────────────────────────────────────────────────────────
108
- * Generic protobuf wire reader (proto3, schema-driven by the caller).
109
- * Wire types: 0=varint, 1=64-bit, 2=length-delimited, 5=32-bit. 3/4 (groups)
110
- * are obsolete and rejected (we cannot skip them without a schema).
111
- * ──────────────────────────────────────────────────────────────────────── */
112
- const WIRE_VARINT = 0;
113
- const WIRE_64 = 1;
114
- const WIRE_LEN = 2;
115
- const WIRE_32 = 5;
116
- function readVarint(buf, pos) {
117
- let shift = 0;
118
- let value = 0;
119
- while (pos < buf.length) {
120
- const b = buf[pos++];
121
- value += (b & 0x7f) * 2 ** shift;
122
- if ((b & 0x80) === 0)
123
- return { value, pos };
124
- shift += 7;
125
- if (shift > 63)
126
- break;
127
- }
128
- throw new PreparedDecodeError("truncated or malformed varint");
129
- }
130
- /**
131
- * Decode one protobuf message into its fields. Repeated fields appear multiple
132
- * times in the returned array (we never silently collapse them). Unknown field
133
- * numbers are still parsed (and skipped by callers) so an honest-but-evolved
134
- * encoding is tolerated; only structurally impossible bytes throw.
135
- */
136
- function decodeMessage(buf) {
137
- const out = [];
138
- let pos = 0;
139
- while (pos < buf.length) {
140
- const { value: tag, pos: p1 } = readVarint(buf, pos);
141
- pos = p1;
142
- const field = Math.floor(tag / 8);
143
- const wire = tag & 0x7;
144
- if (field === 0)
145
- throw new PreparedDecodeError("invalid field number 0");
146
- if (wire === WIRE_LEN) {
147
- const { value: len, pos: p2 } = readVarint(buf, pos);
148
- pos = p2;
149
- if (len < 0 || pos + len > buf.length) {
150
- throw new PreparedDecodeError("length-delimited field overruns buffer");
151
- }
152
- out.push({ field, wire, bytes: buf.subarray(pos, pos + len) });
153
- pos += len;
154
- }
155
- else if (wire === WIRE_VARINT) {
156
- const start = pos;
157
- const { value, pos: p2 } = readVarint(buf, pos);
158
- pos = p2;
159
- out.push({ field, wire, varint: value, varintBytes: buf.subarray(start, p2) });
160
- }
161
- else if (wire === WIRE_64) {
162
- if (pos + 8 > buf.length)
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
- // transfer's executeBefore deadline read back as absent on the live wire.)
168
- out.push({ field, wire, fixed64Bytes: buf.subarray(pos, pos + 8) });
169
- pos += 8;
170
- }
171
- else if (wire === WIRE_32) {
172
- if (pos + 4 > buf.length)
173
- throw new PreparedDecodeError("32-bit field overruns buffer");
174
- pos += 4;
175
- out.push({ field, wire });
176
- }
177
- else {
178
- throw new PreparedDecodeError(`unsupported/obsolete wire type ${wire}`);
179
- }
180
- }
181
- return out;
182
- }
183
- /** All length-delimited submessages for a given field number, in order. */
184
- function lenFields(fields, field) {
185
- return fields
186
- .filter((f) => f.field === field && f.wire === WIRE_LEN && f.bytes !== undefined)
187
- .map((f) => f.bytes);
188
- }
189
- /**
190
- * Strict accessor for a NON-REPEATED length-delimited field: returns its single
191
- * submessage, `undefined` if absent, and THROWS if it occurs more than once.
192
- *
193
- * Why this exists (BYPASS: amount-inflation via duplicate oneof / field):
194
- * the protobuf wire format mandates LAST-occurrence-wins for a non-repeated
195
- * scalar or `oneof` member, and Canton's spec-conformant ScalaPB parser (which
196
- * the participant runs on `execute` to recompute the V2 hash and interpret the
197
- * transaction) follows that rule. A naive hand-rolled reader that takes the
198
- * FIRST occurrence (our old `lenField`) would diverge: an attacker can place
199
- * `Value.numeric` twice inside the SAME amount `Value` — a decoy "1.0" first,
200
- * the real "9999.0" second — and the first-wins reader sees the decoy while the
201
- * participant executes the inflated amount. There is no legitimate reason for a
202
- * non-repeated field to appear twice, so we FAIL CLOSED on any duplicate rather
203
- * than guess which occurrence the participant will honour. This makes our decode
204
- * unambiguous and forces it to agree with the spec parser for everything we
205
- * read (amount/party/text/record/label/value).
206
- */
207
- function lenFieldUnique(fields, field, what) {
208
- const all = lenFields(fields, field);
209
- if (all.length > 1) {
210
- throw new PreparedDecodeError(`non-repeated field ${field} (${what}) appears ${all.length} times — ` +
211
- `ambiguous encoding (last-occurrence-wins on the participant), refusing to sign`);
212
- }
213
- return all[0];
214
- }
215
- /**
216
- * Number of length-delimited occurrences of a field (for duplicate detection on
217
- * scalar `Value` oneof members, which must each appear at most once).
218
- */
219
- function countLenField(fields, field) {
220
- return lenFields(fields, field).length;
221
- }
222
- /** Decode a length-delimited field as UTF-8 (strict). */
223
- function utf8(bytes) {
224
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
225
- }
226
- /** All WIRE_VARINT occurrences of a field number, in order. */
227
- function varintFields(fields, field) {
228
- return fields.filter((f) => f.field === field && f.wire === WIRE_VARINT && f.varintBytes !== undefined);
229
- }
230
- /** Number of WIRE_VARINT occurrences of a field (duplicate-oneof detection). */
231
- function countVarintField(fields, field) {
232
- return varintFields(fields, field).length;
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
- }
281
- /**
282
- * Re-decode a raw varint byte-sequence as a BigInt — full int64 precision, no
283
- * `2**shift` float loss. proto3 int64 is up to 10 bytes (64 data bits). We cap
284
- * the data shift at 63 bits and fail closed on anything longer/malformed rather
285
- * than silently truncating. (`readVarint` already validated framing during
286
- * decode; this re-reads the SAME bytes precisely.)
287
- */
288
- function varintToBigInt(bytes) {
289
- let value = 0n;
290
- let shift = 0n;
291
- for (let i = 0; i < bytes.length; i++) {
292
- const b = bytes[i];
293
- value += BigInt(b & 0x7f) << shift;
294
- if ((b & 0x80) === 0)
295
- return value;
296
- shift += 7n;
297
- if (shift > 63n)
298
- break;
299
- }
300
- throw new PreparedDecodeError("truncated or over-long varint");
301
- }
302
- /**
303
- * Strict accessor for a NON-REPEATED varint field (Daml Int / Time): returns its
304
- * single value as a BigInt, `undefined` if absent, and THROWS if it occurs more
305
- * than once. Same fail-closed rationale as `lenFieldUnique`: the wire format is
306
- * last-occurrence-wins for a non-repeated scalar, so a duplicate would let a
307
- * decoy-first/real-second split diverge our read from the participant's. There
308
- * is no legitimate reason for nonce/expiresAt to appear twice.
309
- */
310
- function varintFieldUnique(fields, field, what) {
311
- const all = varintFields(fields, field);
312
- if (all.length > 1) {
313
- throw new PreparedDecodeError(`non-repeated varint field ${field} (${what}) appears ${all.length} times — ` +
314
- `ambiguous encoding (last-occurrence-wins on the participant), refusing to sign`);
315
- }
316
- const only = all[0];
317
- if (only === undefined)
318
- return undefined;
319
- return varintToBigInt(only.varintBytes);
320
- }
321
- /* ────────────────────────────────────────────────────────────────────────
322
- * Daml `Value` (the choice-argument tree). We only need the leaves that carry
323
- * security-relevant data: party (recipients), numeric (amount), text (e.g.
324
- * instrument id). We descend record / list / optional / variant containers.
325
- * ──────────────────────────────────────────────────────────────────────── */
326
- // Value oneof tags (com.daml.ledger.api.v2.Value.Sum).
327
- // unit=1 bool=2 int64=3 date=4 timestamp=5 numeric=6 party=7 text=8
328
- // contract_id=9 optional=10 list=11 text_map=12 gen_map=13 record=14
329
- // variant=15 enum=16
330
- const V_UNIT = 1; // Value.unit (Daml `()`; google.protobuf.Empty). Non-party leaf.
331
- const V_BOOL = 2; // Value.bool. Varint. Non-party leaf.
332
- const V_INT64 = 3; // Value.int64 (Daml `Int`, e.g. TransferCommand.nonce). Varint.
333
- const V_DATE = 4; // Value.date (Daml `Date`; days since epoch). Varint. Non-party leaf.
334
- const V_TIMESTAMP = 5; // Value.timestamp (Daml `Time`, e.g. expiresAt). SFIXED64 µs (wire type 1, NOT varint).
335
- const V_NUMERIC = 6;
336
- const V_PARTY = 7;
337
- const V_TEXT = 8;
338
- const V_CONTRACT_ID = 9; // Value.contract_id (e.g. transfer.inputHoldingCids). Non-party leaf.
339
- const V_OPTIONAL = 10;
340
- const V_LIST = 11;
341
- const V_TEXT_MAP = 12; // Value.text_map (TextMap; string keys, Value values)
342
- const V_GEN_MAP = 13; // Value.gen_map (GenMap; Value keys AND Value values)
343
- const V_RECORD = 14;
344
- const V_VARIANT = 15;
345
- const V_ENUM = 16; // Value.enum (Identifier + constructor; carries no Party). Non-party leaf.
346
- /**
347
- * The COMPLETE com.daml.ledger.api.v2.interactive `Value` oneof member set. We
348
- * fail closed (refuse to sign) on ANY `Value` that sets a member outside this
349
- * set: the leaf reader + the foreign-party backstop only recognize these, so an
350
- * unknown/future member's subtree would be silently dropped — and could hide a
351
- * foreign recipient party. Mirrors the codebase's fail-closed stance on unknown
352
- * node versions / node types.
353
- */
354
- const COMPLETE_VALUE_MEMBERS = new Set([
355
- V_UNIT,
356
- V_BOOL,
357
- V_INT64,
358
- V_DATE,
359
- V_TIMESTAMP,
360
- V_NUMERIC,
361
- V_PARTY,
362
- V_TEXT,
363
- V_CONTRACT_ID,
364
- V_OPTIONAL,
365
- V_LIST,
366
- V_TEXT_MAP,
367
- V_GEN_MAP,
368
- V_RECORD,
369
- V_VARIANT,
370
- V_ENUM,
371
- ]);
372
- // Record / RecordField / List / Optional / Variant inner field numbers.
373
- const RECORD_FIELDS = 2;
374
- const RF_LABEL = 1;
375
- const RF_VALUE = 2;
376
- const LIST_ELEMENTS = 1;
377
- const OPTIONAL_VALUE = 1;
378
- const VARIANT_VALUE = 3;
379
- // GenMap / TextMap inner field numbers. Both wrap a repeated `Entry` at field 1;
380
- // GenMap.Entry has Value key=1 + Value value=2, TextMap.Entry has string key=1 +
381
- // Value value=2 (so only TextMap *values* can carry a party leaf).
382
- const MAP_ENTRIES = 1;
383
- const ENTRY_KEY = 1;
384
- const ENTRY_VALUE = 2;
385
- const MAX_DEPTH = 64;
386
- /** Decode a `Value`'s record fields (in order). Throws if it is not a record. */
387
- function recordEntries(value) {
388
- assertSingleValueMember(value);
389
- const v = decodeMessage(value);
390
- const rec = lenFieldUnique(v, V_RECORD, "Value.record");
391
- if (rec === undefined)
392
- throw new PreparedDecodeError("expected a Value.record");
393
- const recFields = decodeMessage(rec);
394
- const entries = lenFields(recFields, RECORD_FIELDS).map((rf) => {
395
- const f = decodeMessage(rf);
396
- // A RecordField's label and value are each non-repeated; reject duplicates
397
- // so a last-wins parser cannot disagree with us about a field's value.
398
- const labelBytes = lenFieldUnique(f, RF_LABEL, "RecordField.label");
399
- const valBytes = lenFieldUnique(f, RF_VALUE, "RecordField.value");
400
- if (valBytes === undefined)
401
- throw new PreparedDecodeError("record field missing value");
402
- return { label: labelBytes ? utf8(labelBytes) : "", value: valBytes };
403
- });
404
- // Reject duplicate NON-EMPTY labels: by-label lookup would pick the first
405
- // while a last-wins consumer keying by label would pick the last (decoy/real
406
- // split). Empty labels (normalized encodings) are positional, so allowed.
407
- const seen = new Set();
408
- for (const e of entries) {
409
- if (e.label === "")
410
- continue;
411
- if (seen.has(e.label)) {
412
- throw new PreparedDecodeError(`record contains duplicate field label ${JSON.stringify(e.label)} — ` +
413
- `ambiguous encoding, refusing to sign`);
414
- }
415
- seen.add(e.label);
416
- }
417
- return entries;
418
- }
419
- /**
420
- * A Daml `Value` is a `oneof sum` — at most one member may be set. A
421
- * spec-conformant parser keeps the LAST member it sees within a oneof, so if an
422
- * attacker sets two members (or the same member twice) our by-type reads could
423
- * pick a different one than the participant. Enforce that the scalar/structural
424
- * members we ever read each occur AT MOST ONCE and that no two DIFFERENT members
425
- * are present simultaneously. Fail closed on any ambiguity.
426
- */
427
- function assertSingleValueMember(value) {
428
- const v = decodeMessage(value);
429
- // Length-delimited oneof members.
430
- const lenMembers = [
431
- { tag: V_NUMERIC, what: "Value.numeric" },
432
- { tag: V_PARTY, what: "Value.party" },
433
- { tag: V_TEXT, what: "Value.text" },
434
- { tag: V_RECORD, what: "Value.record" },
435
- { tag: V_OPTIONAL, what: "Value.optional" },
436
- { tag: V_LIST, what: "Value.list" },
437
- { tag: V_TEXT_MAP, what: "Value.text_map" },
438
- { tag: V_GEN_MAP, what: "Value.gen_map" },
439
- { tag: V_VARIANT, what: "Value.variant" },
440
- ];
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.
444
- const varintMembers = [
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 = [
453
- { tag: V_TIMESTAMP, what: "Value.timestamp" },
454
- ];
455
- let present = 0;
456
- for (const m of lenMembers) {
457
- const count = countLenField(v, m.tag);
458
- if (count > 1) {
459
- throw new PreparedDecodeError(`Value oneof member ${m.what} set ${count} times — ambiguous encoding ` +
460
- `(last-occurrence-wins on the participant), refusing to sign`);
461
- }
462
- if (count === 1)
463
- present++;
464
- }
465
- for (const m of varintMembers) {
466
- const count = countVarintField(v, m.tag);
467
- if (count > 1) {
468
- throw new PreparedDecodeError(`Value oneof member ${m.what} set ${count} times — ambiguous encoding ` +
469
- `(last-occurrence-wins on the participant), refusing to sign`);
470
- }
471
- if (count === 1)
472
- present++;
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
- }
483
- if (present > 1) {
484
- throw new PreparedDecodeError("Value sets more than one oneof member — ambiguous encoding, refusing to sign");
485
- }
486
- }
487
- /**
488
- * Read a leaf scalar (party/numeric/text) out of a `Value`, if it is one.
489
- *
490
- * Hardened against the duplicate-oneof amount-inflation bypass: we first assert
491
- * the `Value` sets at most one oneof member, each at most once
492
- * (`assertSingleValueMember`), then read that single member with the strict
493
- * unique accessor. A `Value` carrying `Value.numeric` twice (decoy "1.0" then
494
- * real "9999.0") — which a last-wins ScalaPB parser would read as the inflated
495
- * amount — is rejected here BEFORE any amount comparison, instead of silently
496
- * taking the first (decoy) occurrence. Fail closed.
497
- */
498
- function leafOf(value) {
499
- assertSingleValueMember(value);
500
- const v = decodeMessage(value);
501
- const party = lenFieldUnique(v, V_PARTY, "Value.party");
502
- if (party !== undefined)
503
- return { kind: "party", value: utf8(party) };
504
- const numeric = lenFieldUnique(v, V_NUMERIC, "Value.numeric");
505
- if (numeric !== undefined)
506
- return { kind: "numeric", value: utf8(numeric) };
507
- const text = lenFieldUnique(v, V_TEXT, "Value.text");
508
- if (text !== undefined)
509
- return { kind: "text", value: utf8(text) };
510
- // Daml Int / Time leaves (e.g. TransferCommand.nonce / expiresAt). Read as
511
- // BigInt-precise decimal strings via the unique varint accessor.
512
- const int64 = varintFieldUnique(v, V_INT64, "Value.int64");
513
- // Daml-LF encodes `Value.int64` (Daml `Int`, e.g. TransferCommand.nonce) as a
514
- // protobuf SINT64 — a ZIGZAG varint, NOT a plain two's-complement int64. The
515
- // raw varint w decodes to the signed value zigzag(w) = (w >> 1) ^ -(w & 1):
516
- // nonce 1 is on the wire as varint 2, nonce 2 as 4, -1 as 1, 0 as 0. The
517
- // earlier code decoded it as a plain int64, so every nonce >= 1 read as 2n —
518
- // the v1 nonce pin then matched ONLY nonce 0 and rejected every later payment
519
- // (the unit fixture also plain-encoded, hiding it: fixture-vs-reality, like the
520
- // expectedDso Optional bug). Zigzag-decode so the value equals the participant's
521
- // signed read (a negative nonce still decodes negative for the sanity check).
522
- if (int64 !== undefined)
523
- return { kind: "int64", value: zigzagDecodeInt64(int64).toString() };
524
- // Daml `Value.timestamp` (`Time`, µs since epoch, e.g. the transfer's
525
- // executeBefore deadline) is a protobuf SFIXED64 — wire type 1, little-endian 8
526
- // bytes — NOT a varint. The earlier code read it via the varint accessor, so on
527
- // the real wire (where the participant emits `field 5, wire 1`) the timestamp
528
- // read back as ABSENT — the same fixture-vs-reality trap as the zigzag-nonce /
529
- // Optional-DSO bugs: a varint-encoded fixture passed while the live fixed64 wire
530
- // failed, false-tripping the deadline-fail-closed check on a legitimate transfer.
531
- // Read 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");
534
- if (timestamp !== undefined)
535
- return { kind: "timestamp", value: timestamp.toString() };
536
- return undefined;
537
- }
538
- /** Decode a protobuf SINT64 zigzag varint to its signed BigInt value:
539
- * zigzag(w) = (w >> 1) ^ -(w & 1). Daml-LF serializes `Value.int64` as sint64,
540
- * so a wire varint of 0 is 0, 1 is -1, 2 is 1, 3 is -2, 4 is 2, … This is what
541
- * the participant signs, so the agent must decode it the same way (decoding it
542
- * as a plain int64 read every nonce >= 1 as 2n). */
543
- function zigzagDecodeInt64(u) {
544
- return (u >> 1n) ^ -(u & 1n);
545
- }
546
- /**
547
- * Fail closed on a `Value` that sets any oneof member outside the COMPLETE known
548
- * set. The leaf reader + the backstop's container descent recognize only the
549
- * standard members, so an unknown/future member's subtree would be silently
550
- * dropped — and could hide a foreign recipient party. Refuse rather than guess.
551
- */
552
- function assertKnownValueMembers(value) {
553
- for (const f of decodeMessage(value)) {
554
- if (!COMPLETE_VALUE_MEMBERS.has(f.field)) {
555
- throw new PreparedDecodeError(`Daml Value sets an unknown oneof member (field ${f.field}) — refusing to sign ` +
556
- `(cannot prove the member carries no foreign recipient party)`);
557
- }
558
- }
559
- }
560
- /**
561
- * Recursively collect every party-typed leaf in a `Value` tree, EXCEPT those
562
- * sitting inside an `instrumentId`-shaped sub-record (admin party at a
563
- * non-recipient position). Used for the foreign-recipient backstop. We descend
564
- * every Value container that can hold a party leaf: records, lists, optionals,
565
- * variants, and BOTH map shapes — GenMap (party can hide in a Value key OR a
566
- * Value value) and TextMap (string keys cannot be parties, so only values are
567
- * descended). Skipping the map oneof members would let a relay smuggle an extra
568
- * recipient inside a GenMap and slip past the backstop (defense-in-depth: the
569
- * receiver itself must still be a plain Party at the transfer.receiver position,
570
- * which extractTransfer pins, so this is not a known redirect exploit). The
571
- * caller excludes the instrument admin from the recipient set without trusting
572
- * its value. Fails closed on any unknown Value oneof member (assertKnownValueMembers).
573
- */
574
- function collectPartyLeaves(value, out, depth) {
575
- if (depth > MAX_DEPTH)
576
- throw new PreparedDecodeError("Value nesting too deep");
577
- // Fail closed on any Value oneof member outside the complete known set BEFORE
578
- // we walk it: the leaf reader + container descent below recognize only the
579
- // standard members, so an unknown/future member's subtree would otherwise be
580
- // silently dropped — and could hide a foreign recipient party from this
581
- // backstop. (Known non-party scalars — unit/bool/date/contract_id/enum — are
582
- // in the set: leafOf returns undefined for them and no container matches, so
583
- // they are correctly ignored, not rejected.)
584
- assertKnownValueMembers(value);
585
- const leaf = leafOf(value);
586
- if (leaf) {
587
- if (leaf.kind === "party")
588
- out.push(leaf.value);
589
- return; // scalar leaf — nothing to recurse into
590
- }
591
- // Not a scalar leaf — it must be a single structural oneof member. Reject any
592
- // ambiguous (duplicate/multi-member) Value before descending so a smuggled
593
- // extra party cannot hide behind a last-wins parser disagreement.
594
- assertSingleValueMember(value);
595
- const v = decodeMessage(value);
596
- // record → recurse each field value
597
- const rec = lenFieldUnique(v, V_RECORD, "Value.record");
598
- if (rec !== undefined) {
599
- const recFields = decodeMessage(rec);
600
- for (const rf of lenFields(recFields, RECORD_FIELDS)) {
601
- const f = decodeMessage(rf);
602
- const valBytes = lenFieldUnique(f, RF_VALUE, "RecordField.value");
603
- if (valBytes !== undefined)
604
- collectPartyLeaves(valBytes, out, depth + 1);
605
- }
606
- return;
607
- }
608
- // list → recurse elements
609
- const list = lenFieldUnique(v, V_LIST, "Value.list");
610
- if (list !== undefined) {
611
- for (const el of lenFields(decodeMessage(list), LIST_ELEMENTS)) {
612
- collectPartyLeaves(el, out, depth + 1);
613
- }
614
- return;
615
- }
616
- // optional → recurse inner value
617
- const opt = lenFieldUnique(v, V_OPTIONAL, "Value.optional");
618
- if (opt !== undefined) {
619
- const inner = lenFieldUnique(decodeMessage(opt), OPTIONAL_VALUE, "Optional.value");
620
- if (inner !== undefined)
621
- collectPartyLeaves(inner, out, depth + 1);
622
- return;
623
- }
624
- // variant → recurse inner value
625
- const variant = lenFieldUnique(v, V_VARIANT, "Value.variant");
626
- if (variant !== undefined) {
627
- const inner = lenFieldUnique(decodeMessage(variant), VARIANT_VALUE, "Variant.value");
628
- if (inner !== undefined)
629
- collectPartyLeaves(inner, out, depth + 1);
630
- return;
631
- }
632
- // gen_map → recurse BOTH the key and the value of every entry. In a GenMap
633
- // both Entry.key and Entry.value are full `Value`s, so a party can hide in
634
- // either; descend both so a smuggled recipient cannot escape the backstop.
635
- const genMap = lenFieldUnique(v, V_GEN_MAP, "Value.gen_map");
636
- if (genMap !== undefined) {
637
- for (const entry of lenFields(decodeMessage(genMap), MAP_ENTRIES)) {
638
- const e = decodeMessage(entry);
639
- const key = lenFieldUnique(e, ENTRY_KEY, "GenMap.Entry.key");
640
- if (key !== undefined)
641
- collectPartyLeaves(key, out, depth + 1);
642
- const val = lenFieldUnique(e, ENTRY_VALUE, "GenMap.Entry.value");
643
- if (val !== undefined)
644
- collectPartyLeaves(val, out, depth + 1);
645
- }
646
- return;
647
- }
648
- // text_map → recurse entry VALUES only (TextMap keys are plain strings, never
649
- // parties), so a party leaf can only appear in a value.
650
- const textMap = lenFieldUnique(v, V_TEXT_MAP, "Value.text_map");
651
- if (textMap !== undefined) {
652
- for (const entry of lenFields(decodeMessage(textMap), MAP_ENTRIES)) {
653
- const val = lenFieldUnique(decodeMessage(entry), ENTRY_VALUE, "TextMap.Entry.value");
654
- if (val !== undefined)
655
- collectPartyLeaves(val, out, depth + 1);
656
- }
657
- }
658
- }
659
- /**
660
- * Collect every party-typed leaf in a `GlobalKeyWithMaintainers` (the contract-
661
- * key metadata attached to Create.key (field 8) / Exercise.key (field 15) /
662
- * Fetch.key (field 9) / QueryByKey.key (field 5)). A contract key carries
663
- * parties in TWO positions, BOTH covered by the agent's signature once a V3
664
- * hashing scheme is negotiated (the V3 NodeHashBuilder hashes keyOpt +
665
- * maintainers): the `maintainers` (GKWM field 2, repeated string party) AND the
666
- * inner contract-key `Value` (GKWM.key=1 → GlobalKey.key=3), which is a full
667
- * Daml `Value` tree that can itself hold a party leaf. Both were previously
668
- * invisible to the foreign-party backstop, so a relay could place ATTACKER ONLY
669
- * in a key position and slip past `assertNoForeignParties` (the file's stated
670
- * invariant is that a party introduced ANYWHERE the signature covers is visible
671
- * to the backstop). We scan both and feed them into the backstop's `elsewhere`
672
- * set — a key party is NEVER a legitimate recipient position, so it must equal
673
- * an already-allowed party or be rejected. Fail-closed; honest CC
674
- * transfers/commands carry no contract key, so this never affects the happy path.
675
- */
676
- function collectGlobalKeyWithMaintainersParties(gkwm, out, depth) {
677
- if (depth > MAX_DEPTH)
678
- throw new PreparedDecodeError("contract key nesting too deep");
679
- const f = decodeMessage(gkwm);
680
- // maintainers (repeated string party) — authorization parties on the key.
681
- out.push(...stringFields(f, GKWM_MAINTAINERS));
682
- // the inner GlobalKey.key Value (the contract-key payload) — descend it as a
683
- // full Daml Value so a party leaf hidden inside the key surfaces too.
684
- const globalKey = lenFieldUnique(f, GKWM_KEY, "GlobalKeyWithMaintainers.key");
685
- if (globalKey !== undefined) {
686
- const keyValue = lenFieldUnique(decodeMessage(globalKey), GLOBALKEY_KEY, "GlobalKey.key");
687
- if (keyValue !== undefined)
688
- collectPartyLeaves(keyValue, out, depth + 1);
689
- }
690
- }
691
- /* ────────────────────────────────────────────────────────────────────────
692
- * PreparedTransaction structural descent.
693
- * ──────────────────────────────────────────────────────────────────────── */
694
- // PreparedTransaction / Metadata / DamlTransaction / Node field numbers.
695
- // Schema-pinned against com.daml.ledger.api.v2.interactive (Canton 3.x):
696
- // PreparedTransaction.transaction=1, .metadata=2
697
- // Metadata.submitter_info=2 ; SubmitterInfo.act_as=1
698
- // DamlTransaction.version=1, .roots=2, .nodes=3
699
- // DamlTransaction.Node.node_id=1, oneof versioned_node { v1=1000 }
700
- // transaction.v1.Node oneof node_type { create=1 fetch=2 exercise=3
701
- // rollback=4 query_by_key=5 }
702
- // Exercise.choice_id=9, .chosen_value=10, .children=12, .exercise_result=13
703
- // Create.argument=5 ; Rollback.children=1
704
- const PT_TRANSACTION = 1;
705
- const PT_METADATA = 2;
706
- const MD_SUBMITTER_INFO = 2;
707
- const SI_ACT_AS = 1;
708
- // Metadata "needs to be signed" block (interactive_submission_service.proto):
709
- // submitter_info=2 synchronizer_id=3 mediator_group=4 transaction_uuid=5
710
- // preparation_time=6 input_contracts=7 min_ledger_effective_time=9
711
- // max_ledger_effective_time=10 max_record_time=11
712
- // (field 8 = global_key_mapping is the ONLY field NOT signed.) Every signed
713
- // field is covered by the agent's signature, so verify must constrain the ones
714
- // that affect WHERE/WHEN the transfer lands or WHICH parties it touches.
715
- const MD_SYNCHRONIZER_ID = 3;
716
- const MD_PREPARATION_TIME = 6;
717
- const MD_INPUT_CONTRACTS = 7;
718
- const MD_MIN_LEDGER_EFFECTIVE_TIME = 9;
719
- const MD_MAX_LEDGER_EFFECTIVE_TIME = 10;
720
- const MD_MAX_RECORD_TIME = 11;
721
- // Metadata.InputContract: oneof contract { v1 Create = 1 }, created_at=1000,
722
- // event_blob=1002. We descend the Create v1 argument for the party backstop.
723
- const IC_V1 = 1;
724
- const DT_ROOTS = 2;
725
- const DT_NODES = 3;
726
- const NODE_ID = 1;
727
- const NODE_V1 = 1000;
728
- // transaction.v1.Node.node_type oneof members.
729
- const V1NODE_CREATE = 1;
730
- const V1NODE_FETCH = 2;
731
- const V1NODE_EXERCISE = 3;
732
- const V1NODE_ROLLBACK = 4;
733
- const V1NODE_QUERY_BY_KEY = 5;
734
- // interactive.transaction.v1.Exercise field numbers (interactive_submission_data.proto).
735
- const EX_CONTRACT_ID = 2; // Exercise.contract_id — the exercised target contract.
736
- const EX_TEMPLATE_ID = 4;
737
- const EX_SIGNATORIES = 5;
738
- const EX_STAKEHOLDERS = 6;
739
- const EX_ACTING_PARTIES = 7;
740
- const EX_CHOICE_ID = 9;
741
- const EX_CHOSEN_VALUE = 10;
742
- const EX_CHILDREN = 12;
743
- const EX_RESULT = 13;
744
- const EX_CHOICE_OBSERVERS = 14;
745
- const EX_KEY = 15; // Exercise.key : optional GlobalKeyWithMaintainers
746
- // interactive.transaction.v1.Create field numbers.
747
- // Field numbers CONFIRMED against a real MainNet PreparedTransaction (see
748
- // src/__fixtures__/): lf_version=1, contract_id=2, package_name=3, template_id=4,
749
- // create_argument=5, signatories=6, stakeholders=7, key=8.
750
- const CREATE_CONTRACT_ID = 2; // Create.contract_id — identity of the created/input contract.
751
- const CREATE_TEMPLATE_ID = 4; // Create.template_id : Identifier (same shape as Exercise.template_id).
752
- const CREATE_ARGUMENT = 5;
753
- const CREATE_SIGNATORIES = 6;
754
- const CREATE_STAKEHOLDERS = 7;
755
- const CREATE_KEY = 8; // Create.key : optional GlobalKeyWithMaintainers
756
- // interactive.transaction.v1.Fetch field numbers (party lists; no Daml Value arg).
757
- const FETCH_SIGNATORIES = 5;
758
- const FETCH_STAKEHOLDERS = 6;
759
- const FETCH_ACTING_PARTIES = 7;
760
- const FETCH_KEY = 9; // Fetch.key : optional GlobalKeyWithMaintainers
761
- // interactive.transaction.v1.QueryByKey + GlobalKeyWithMaintainers + GlobalKey.
762
- const QBK_KEY = 5; // QueryByKey.key : GlobalKeyWithMaintainers
763
- const GKWM_KEY = 1; // GlobalKeyWithMaintainers.key : GlobalKey
764
- const GKWM_MAINTAINERS = 2; // GlobalKeyWithMaintainers.maintainers (repeated string party)
765
- const GLOBALKEY_KEY = 3; // GlobalKey.key : Value (the contract-key Value tree)
766
- const ROLLBACK_CHILDREN = 1;
767
- // Identifier (com.daml.ledger.api.v2.value.Identifier): module_name=2, entity_name=3.
768
- // (package_id=1 is intentionally NOT pinned — it varies with package upgrades.)
769
- const ID_PACKAGE_ID = 1;
770
- const ID_MODULE_NAME = 2;
771
- const ID_ENTITY_NAME = 3;
772
- /** The transfer choice on a TransferFactory. */
773
- const TRANSFER_CHOICE = "TransferFactory_Transfer";
774
- /** All values of a repeated `string` field, decoded UTF-8 (in order). */
775
- function stringFields(fields, field) {
776
- return lenFields(fields, field).map(utf8);
777
- }
778
- /**
779
- * Decode an `Identifier` (value.proto) to its `module:entity` qualified name.
780
- * The package id (field 1) is deliberately DROPPED: it changes across package
781
- * upgrades, so pinning it would break honest transfers; module + entity together
782
- * unambiguously name the template. Returns undefined if the identifier is empty
783
- * or does not carry both names.
784
- */
785
- function identifierQualifiedName(idBytes) {
786
- // An Identifier may be serialized either as the structured message
787
- // {package_id=1, module_name=2, entity_name=3} OR (in some Canton encodings of
788
- // the interactive node template_id) as a single flat string "pkg:module:entity"
789
- // / "module:entity". Handle both: try the structured fields first; only if a
790
- // structured decode succeeds AND carries a module/entity field do we use it. A
791
- // flat string is NOT valid protobuf, so `decodeMessage` may throw — fall back
792
- // to the flat-string interpretation in that case.
793
- let moduleName;
794
- let entityName;
795
- let structuredHadOtherFields = false;
796
- try {
797
- const f = decodeMessage(idBytes);
798
- moduleName = lenFieldUnique(f, ID_MODULE_NAME, "Identifier.module_name");
799
- entityName = lenFieldUnique(f, ID_ENTITY_NAME, "Identifier.entity_name");
800
- structuredHadOtherFields = f.some((x) => x.field === ID_PACKAGE_ID);
801
- }
802
- catch {
803
- /* not structured protobuf — treat as a flat string below */
804
- }
805
- if (moduleName !== undefined && entityName !== undefined) {
806
- return `${utf8(moduleName)}:${utf8(entityName)}`;
807
- }
808
- // A structured Identifier with a package_id but no module/entity is malformed
809
- // for our purpose; only fall through to flat-string when it did NOT look like a
810
- // structured Identifier at all.
811
- if (structuredHadOtherFields)
812
- return undefined;
813
- // Flat-string form. A template id string is "package:Module.Path:Entity" or
814
- // "Module.Path:Entity"; normalize to the trailing two colon-separated segments
815
- // (module:entity), dropping a leading package-id when present.
816
- let flat;
817
- try {
818
- flat = utf8(idBytes);
819
- }
820
- catch {
821
- return undefined;
822
- }
823
- if (flat.length === 0 || flat.includes("\u0000"))
824
- return undefined;
825
- const parts = flat.split(":");
826
- if (parts.length >= 3)
827
- return `${parts[parts.length - 2]}:${parts[parts.length - 1]}`;
828
- if (parts.length === 2)
829
- return flat;
830
- return undefined;
831
- }
832
- /**
833
- * Decode the inner `transaction.v1.Node` (the node_type oneof). Returns the
834
- * single set member and the per-kind payload we validate. A node that sets NO
835
- * known member, or MORE THAN ONE, is reported as `kind: undefined` so the
836
- * invariant layer rejects it (fail closed — an ambiguous/opaque node could be
837
- * executed by the participant as something other than what we read).
838
- *
839
- * Beyond the Daml `Value` payloads we also collect every node-level
840
- * `repeated string party` field (signatories/stakeholders/acting_parties/
841
- * choice_observers and a QueryByKey's key maintainers) into `partyMeta`, so the
842
- * foreign-party backstop sees a party placed in authorization/visibility
843
- * metadata (a position outside the Value-leaf walk), and the exercised
844
- * template's `module:entity` so the arm can pin WHICH template the choice runs
845
- * against.
846
- */
847
- function decodeV1Node(v1Body) {
848
- const v1n = decodeMessage(v1Body);
849
- // Detect which (if any) node_type oneof members are present. Each is
850
- // non-repeated; a duplicate is ambiguous under last-wins so reject via the
851
- // unique accessor. Count DISTINCT members set — more than one ⇒ reject.
852
- const create = lenFieldUnique(v1n, V1NODE_CREATE, "v1.Node.create");
853
- const fetch = lenFieldUnique(v1n, V1NODE_FETCH, "v1.Node.fetch");
854
- const exercise = lenFieldUnique(v1n, V1NODE_EXERCISE, "v1.Node.exercise");
855
- const rollback = lenFieldUnique(v1n, V1NODE_ROLLBACK, "v1.Node.rollback");
856
- const queryByKey = lenFieldUnique(v1n, V1NODE_QUERY_BY_KEY, "v1.Node.query_by_key");
857
- const present = [create, fetch, exercise, rollback, queryByKey].filter((m) => m !== undefined);
858
- if (present.length === 0)
859
- return { children: [], values: [], partyMeta: [] }; // kind undefined ⇒ reject
860
- if (present.length > 1)
861
- return { children: [], values: [], partyMeta: [] }; // ambiguous oneof ⇒ reject
862
- if (exercise !== undefined) {
863
- const exFields = decodeMessage(exercise);
864
- const choiceIdBytes = lenFieldUnique(exFields, EX_CHOICE_ID, "Exercise.choice_id");
865
- const chosen = lenFieldUnique(exFields, EX_CHOSEN_VALUE, "Exercise.chosen_value");
866
- const result = lenFieldUnique(exFields, EX_RESULT, "Exercise.exercise_result");
867
- const children = lenFields(exFields, EX_CHILDREN).map(utf8);
868
- const tmpl = lenFieldUnique(exFields, EX_TEMPLATE_ID, "Exercise.template_id");
869
- const cidBytes = lenFieldUnique(exFields, EX_CONTRACT_ID, "Exercise.contract_id");
870
- const values = [];
871
- if (chosen !== undefined)
872
- values.push(chosen);
873
- if (result !== undefined)
874
- values.push(result);
875
- const partyMeta = [
876
- ...stringFields(exFields, EX_SIGNATORIES),
877
- ...stringFields(exFields, EX_STAKEHOLDERS),
878
- ...stringFields(exFields, EX_ACTING_PARTIES),
879
- ...stringFields(exFields, EX_CHOICE_OBSERVERS),
880
- ];
881
- // Exercise.key (field 15, optional GlobalKeyWithMaintainers): a contract-key
882
- // party (maintainer or inner key Value leaf) is a position the agent's
883
- // signature covers under V3 hashing — scan it so a relay cannot hide a
884
- // foreign party there (closes the contract-key backstop blind spot).
885
- const exKey = lenFieldUnique(exFields, EX_KEY, "Exercise.key");
886
- if (exKey !== undefined)
887
- collectGlobalKeyWithMaintainersParties(exKey, partyMeta, 0);
888
- const dec = choiceIdBytes !== undefined && chosen !== undefined
889
- ? {
890
- choiceId: utf8(choiceIdBytes),
891
- chosenValue: chosen,
892
- contractId: cidBytes !== undefined ? utf8(cidBytes) : undefined,
893
- templateQualifiedName: tmpl !== undefined ? identifierQualifiedName(tmpl) : undefined,
894
- }
895
- : undefined;
896
- return { kind: "exercise", exercise: dec, children, values, partyMeta };
897
- }
898
- if (create !== undefined) {
899
- const cFields = decodeMessage(create);
900
- const arg = lenFieldUnique(cFields, CREATE_ARGUMENT, "Create.argument");
901
- const cTmpl = lenFieldUnique(cFields, CREATE_TEMPLATE_ID, "Create.template_id");
902
- const partyMeta = [
903
- ...stringFields(cFields, CREATE_SIGNATORIES),
904
- ...stringFields(cFields, CREATE_STAKEHOLDERS),
905
- ];
906
- // Create.key (field 8, optional GlobalKeyWithMaintainers): scan the key
907
- // maintainers + inner contract-key Value so a foreign party placed ONLY in a
908
- // consequence Create's key (a position the V3 hash signs) surfaces.
909
- const cKey = lenFieldUnique(cFields, CREATE_KEY, "Create.key");
910
- if (cKey !== undefined)
911
- collectGlobalKeyWithMaintainersParties(cKey, partyMeta, 0);
912
- return {
913
- kind: "create",
914
- // Capture the created template + argument so an arm can pin a NUMERIC
915
- // (non-party) field of a consequence create (e.g. X402Escrow.amount), which
916
- // the party-leaf backstop cannot see.
917
- create: {
918
- templateQualifiedName: cTmpl !== undefined ? identifierQualifiedName(cTmpl) : undefined,
919
- argument: arg,
920
- },
921
- children: [],
922
- values: arg !== undefined ? [arg] : [],
923
- partyMeta,
924
- };
925
- }
926
- if (rollback !== undefined) {
927
- const rFields = decodeMessage(rollback);
928
- const children = lenFields(rFields, ROLLBACK_CHILDREN).map(utf8);
929
- return { kind: "rollback", children, values: [], partyMeta: [] };
930
- }
931
- if (fetch !== undefined) {
932
- // Fetch carries no Daml `Value` argument, but DOES carry node-level party
933
- // lists (signatories/stakeholders/acting_parties) — collect them so the
934
- // backstop sees a foreign party smuggled onto a fetch consequence.
935
- const fFields = decodeMessage(fetch);
936
- const partyMeta = [
937
- ...stringFields(fFields, FETCH_SIGNATORIES),
938
- ...stringFields(fFields, FETCH_STAKEHOLDERS),
939
- ...stringFields(fFields, FETCH_ACTING_PARTIES),
940
- ];
941
- // Fetch.key (field 9, optional GlobalKeyWithMaintainers): scan its parties
942
- // (maintainers + inner key Value) too.
943
- const fKey = lenFieldUnique(fFields, FETCH_KEY, "Fetch.key");
944
- if (fKey !== undefined)
945
- collectGlobalKeyWithMaintainersParties(fKey, partyMeta, 0);
946
- return { kind: "fetch", children: [], values: [], partyMeta };
947
- }
948
- // query_by_key — carries no Value argument, but its GlobalKeyWithMaintainers
949
- // carries key maintainer parties AND an inner contract-key Value that can hold
950
- // a party leaf; collect BOTH for the backstop (previously only maintainers
951
- // were scanned, so a party hidden in the inner key Value escaped).
952
- const qFields = decodeMessage(queryByKey);
953
- const partyMeta = [];
954
- const key = lenFieldUnique(qFields, QBK_KEY, "QueryByKey.key");
955
- if (key !== undefined)
956
- collectGlobalKeyWithMaintainersParties(key, partyMeta, 0);
957
- return { kind: "query_by_key", children: [], values: [], partyMeta };
958
- }
959
- /**
960
- * Decode a base64 `PreparedTransaction` into the bits we validate.
961
- *
962
- * Enumerates EVERY node in DamlTransaction.nodes — every node-version member and
963
- * every node-type oneof member — recording unrecognized ones as
964
- * present-but-unvalidated (never silently skipping them). The single allowed
965
- * root exercise, no-orphan reachability, and the all-nodes value backstop are
966
- * enforced by the per-arm invariant (`assertSingleAllowedRootExercise`).
967
- */
968
- export function decodePrepared(preparedTransactionB64) {
969
- let bytes;
970
- try {
971
- bytes = Buffer.from(preparedTransactionB64, "base64");
972
- }
973
- catch {
974
- throw new PreparedDecodeError("preparedTransaction is not valid base64");
975
- }
976
- if (bytes.length === 0)
977
- throw new PreparedDecodeError("preparedTransaction decoded to empty bytes");
978
- const top = decodeMessage(bytes);
979
- // metadata.submitter_info.act_as. `transaction` and `metadata` are
980
- // non-repeated: a duplicate would let a last-wins participant read a different
981
- // one than we validate, so reject duplicates (act_as / nodes stay repeated).
982
- const actAs = [];
983
- let synchronizerId;
984
- let preparationTime;
985
- let minLedgerEffectiveTime;
986
- let maxLedgerEffectiveTime;
987
- let maxRecordTime;
988
- const inputContractValues = [];
989
- const inputContractPartyMeta = [];
990
- const inputContracts = [];
991
- const metadata = lenFieldUnique(top, PT_METADATA, "PreparedTransaction.metadata");
992
- if (metadata !== undefined) {
993
- const md = decodeMessage(metadata);
994
- const si = lenFieldUnique(md, MD_SUBMITTER_INFO, "Metadata.submitter_info");
995
- if (si !== undefined) {
996
- for (const a of lenFields(decodeMessage(si), SI_ACT_AS))
997
- actAs.push(utf8(a));
998
- }
999
- // synchronizer_id (field 3) — SIGNED; pinned to caller intent by the arms.
1000
- const sync = lenFieldUnique(md, MD_SYNCHRONIZER_ID, "Metadata.synchronizer_id");
1001
- if (sync !== undefined)
1002
- synchronizerId = utf8(sync);
1003
- // SIGNED timing fields (uint64 µs). Read BigInt-precise; the arms sanity-bound.
1004
- preparationTime = varintFieldUnique(md, MD_PREPARATION_TIME, "Metadata.preparation_time");
1005
- minLedgerEffectiveTime = varintFieldUnique(md, MD_MIN_LEDGER_EFFECTIVE_TIME, "Metadata.min_ledger_effective_time");
1006
- maxLedgerEffectiveTime = varintFieldUnique(md, MD_MAX_LEDGER_EFFECTIVE_TIME, "Metadata.max_ledger_effective_time");
1007
- maxRecordTime = varintFieldUnique(md, MD_MAX_RECORD_TIME, "Metadata.max_record_time");
1008
- // input_contracts (field 7, repeated, SIGNED): descend each InputContract's
1009
- // Create v1 argument so the foreign-party backstop covers parties that appear
1010
- // ONLY inside the authenticated input-contract set (never in a tx node). We
1011
- // ALSO read the Create's signatories/stakeholders/key — the V2 metadata hasher
1012
- // binds disclosed/input contracts via hashNode(toCreateNode) → addCreateNode,
1013
- // which hashes argument + signatories + stakeholders, so those party fields are
1014
- // covered by the agent's signature and a foreign party placed ONLY there must
1015
- // surface to the backstop (closes the input-contract party-coverage asymmetry).
1016
- for (const ic of lenFields(md, MD_INPUT_CONTRACTS)) {
1017
- const icFields = decodeMessage(ic);
1018
- const v1create = lenFieldUnique(icFields, IC_V1, "InputContract.v1");
1019
- if (v1create === undefined) {
1020
- // The InputContract sets no known `contract` oneof member (v1 = field 1).
1021
- // It is carried under an unknown/future encoding whose Create argument we
1022
- // cannot scan — fail closed (mirror the recognized=false treatment of
1023
- // transaction nodes). A foreign party hidden in such an input contract
1024
- // would otherwise escape the all-nodes backstop. (created_at/event_blob
1025
- // are high-numbered non-oneof fields, so an honest InputContract always
1026
- // carries the v1 member here.)
1027
- throw new PreparedDecodeError("Metadata.input_contracts entry sets no known `contract` member (expected v1) — " +
1028
- "refusing to sign (uninspectable input contract that could carry a foreign party)");
1029
- }
1030
- const createFields = decodeMessage(v1create);
1031
- const arg = lenFieldUnique(createFields, CREATE_ARGUMENT, "InputContract.v1.argument");
1032
- if (arg !== undefined)
1033
- inputContractValues.push(arg);
1034
- const icSignatories = stringFields(createFields, CREATE_SIGNATORIES);
1035
- const icStakeholders = stringFields(createFields, CREATE_STAKEHOLDERS);
1036
- inputContractPartyMeta.push(...icSignatories);
1037
- inputContractPartyMeta.push(...icStakeholders);
1038
- const icKey = lenFieldUnique(createFields, CREATE_KEY, "InputContract.v1.key");
1039
- if (icKey !== undefined) {
1040
- collectGlobalKeyWithMaintainersParties(icKey, inputContractPartyMeta, 0);
1041
- }
1042
- // GROUPED view of the same authenticated input contract. The flattened
1043
- // lists above stay exactly as they were (every existing consumer is
1044
- // untouched); this adds the identity + template needed to relate a party
1045
- // occurrence to the contract that explains it, instead of accepting a
1046
- // party by VALUE alone.
1047
- const icTmpl = lenFieldUnique(createFields, CREATE_TEMPLATE_ID, "InputContract.v1.template_id");
1048
- const icCid = lenFieldUnique(createFields, CREATE_CONTRACT_ID, "InputContract.v1.contract_id");
1049
- const icQualified = icTmpl !== undefined ? identifierQualifiedName(icTmpl) : undefined;
1050
- inputContracts.push({
1051
- ...(icCid !== undefined ? { contractId: utf8(icCid) } : {}),
1052
- ...(icQualified !== undefined ? { templateQualifiedName: icQualified } : {}),
1053
- ...(arg !== undefined ? { argument: arg } : {}),
1054
- signatories: icSignatories,
1055
- stakeholders: icStakeholders,
1056
- });
1057
- }
1058
- }
1059
- const transaction = lenFieldUnique(top, PT_TRANSACTION, "PreparedTransaction.transaction");
1060
- if (transaction === undefined) {
1061
- throw new PreparedDecodeError("PreparedTransaction has no transaction");
1062
- }
1063
- const dt = decodeMessage(transaction);
1064
- const roots = lenFields(dt, DT_ROOTS).map(utf8);
1065
- const nodes = [];
1066
- const exercises = [];
1067
- for (const node of lenFields(dt, DT_NODES)) {
1068
- const n = decodeMessage(node);
1069
- // node_id is non-repeated; reject a duplicate (a last-wins parser could
1070
- // disagree about which id this node carries, breaking roots/children
1071
- // reachability matching).
1072
- const nodeIdBytes = lenFieldUnique(n, NODE_ID, "Node.node_id");
1073
- const nodeId = nodeIdBytes !== undefined ? utf8(nodeIdBytes) : "";
1074
- // Versioned-node oneof: the ONLY known member is v1 (1000). If v1 is absent
1075
- // OR any OTHER length-delimited member (beyond node_id) is present, the node
1076
- // is carried under a version we cannot read → record it unrecognized so the
1077
- // invariant rejects it (closes the "hidden under node-version 1001" bypass).
1078
- const v1 = lenFieldUnique(n, NODE_V1, "Node.v1");
1079
- const extraVersioned = n.some((f) => f.wire === WIRE_LEN && f.field !== NODE_ID && f.field !== NODE_V1);
1080
- if (v1 === undefined || extraVersioned) {
1081
- nodes.push({ nodeId, recognizedVersion: false, children: [], values: [], partyMeta: [] });
1082
- continue;
1083
- }
1084
- const dv = decodeV1Node(v1);
1085
- nodes.push({
1086
- nodeId,
1087
- recognizedVersion: true,
1088
- kind: dv.kind,
1089
- exercise: dv.exercise,
1090
- create: dv.create,
1091
- children: dv.children,
1092
- values: dv.values,
1093
- partyMeta: dv.partyMeta,
1094
- });
1095
- if (dv.kind === "exercise" && dv.exercise !== undefined)
1096
- exercises.push(dv.exercise);
1097
- }
1098
- return {
1099
- actAs,
1100
- roots,
1101
- nodes,
1102
- exercises,
1103
- synchronizerId,
1104
- preparationTime,
1105
- minLedgerEffectiveTime,
1106
- maxLedgerEffectiveTime,
1107
- maxRecordTime,
1108
- inputContractValues,
1109
- inputContractPartyMeta,
1110
- inputContracts,
1111
- };
1112
- }
1113
- /** Does `ex` match one of the consequence rules? A pinned rule requires BOTH the
1114
- * choice id and a template from its whitelist; an exercise whose template we
1115
- * could not decode never satisfies a pinned rule (fail-closed — we cannot prove
1116
- * which implementation of the interface would run). */
1117
- function isAllowedConsequence(ex, rules) {
1118
- for (const rule of rules) {
1119
- if (typeof rule === "string") {
1120
- if (rule === ex.choiceId)
1121
- return true;
1122
- continue;
1123
- }
1124
- if (rule.choiceId !== ex.choiceId)
1125
- continue;
1126
- if (ex.templateQualifiedName === undefined)
1127
- continue;
1128
- if (rule.templates.includes(ex.templateQualifiedName))
1129
- return true;
1130
- }
1131
- return false;
1132
- }
1133
- function assertSingleAllowedRootExercise(decoded, allowedChoiceId,
1134
- /** Human-friendly plural ("transfer" / the choice id) used ONLY in the
1135
- * exercise-count messages so each arm keeps its historical wording. */
1136
- countNoun = allowedChoiceId,
1137
- /** Choices permitted ONLY as CONSEQUENCES (descendants) of the single allowed
1138
- * root exercise — the accept path's honest settlement side-effects. Empty for
1139
- * the pay paths (which stay strict). Still bound by the structural guards
1140
- * below: there is exactly ONE root (the allowed choice) and every node —
1141
- * these included — must be reachable from it (no sibling-root / orphan drain
1142
- * leg). A value-MOVING choice is never placed here, so an injected outbound
1143
- * drain is still refused.
1144
- *
1145
- * A bare string permits the choice on ANY template — safe only for choices
1146
- * defined on a TEMPLATE, where the name already identifies the code that
1147
- * runs (`Archive`, the Splice settlement choices). An INTERFACE choice must
1148
- * use the pinned object form: its body is `virtual`, chosen by whichever
1149
- * package implements the interface, so the name alone proves nothing about
1150
- * what executes. See `EVENT_LOG_HOLDINGS_CHANGE`. */
1151
- allowedConsequenceChoices = []) {
1152
- // (1) Exercise-count contract (unchanged messages): exactly one exercise of
1153
- // the allowed choice, and no other exercise of any choice. This runs first so
1154
- // an extra/duplicate/wrong-choice exercise surfaces with the historical
1155
- // message before the structural (root/orphan) checks below.
1156
- const allowedExercises = decoded.exercises.filter((e) => e.choiceId === allowedChoiceId);
1157
- if (allowedExercises.length === 0) {
1158
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains no ${allowedChoiceId} exercise — refusing to sign ` +
1159
- `(possible tampered/compromised relay)`);
1160
- }
1161
- if (allowedExercises.length > 1) {
1162
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains ${allowedExercises.length} ${countNoun} ` +
1163
- `exercises — refusing to sign (possible tampered/compromised relay adding a second leg)`);
1164
- }
1165
- const otherExercises = decoded.exercises.filter((e) => e.choiceId !== allowedChoiceId && !isAllowedConsequence(e, allowedConsequenceChoices));
1166
- if (otherExercises.length > 0) {
1167
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains unexpected exercise(s) ` +
1168
- `${otherExercises.map((e) => JSON.stringify(e.choiceId)).join(", ")} — refusing to sign`);
1169
- }
1170
- // (2) Every node must be fully recognized. An unrecognized node version or an
1171
- // unknown/ambiguous node type is opaque — a participant could execute it under
1172
- // our signature — so fail closed.
1173
- for (const node of decoded.nodes) {
1174
- if (!node.recognizedVersion) {
1175
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) carried ` +
1176
- `under an unrecognized node version — refusing to sign (cannot prove it does not move ` +
1177
- `value under the agent's authority)`);
1178
- }
1179
- if (node.kind === undefined) {
1180
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) with an ` +
1181
- `unknown or ambiguous node type — refusing to sign`);
1182
- }
1183
- // A node typed as an exercise whose choice_id/chosen_value we could NOT fully
1184
- // decode (DecodedExercise undefined) is an OPAQUE exercise: it is invisible to
1185
- // the exercise-count checks above (which key off decoded.exercises, populated
1186
- // only for fully-decoded exercises) yet a participant could run it under the
1187
- // agent's single signature. Fail closed — we cannot prove it is the single
1188
- // allowed transfer. (Closes the exercise-count blind spot where a sibling
1189
- // exercise sets the exercise oneof + choice_id but OMITS chosen_value.)
1190
- if (node.kind === "exercise" && node.exercise === undefined) {
1191
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains an exercise node (id ${JSON.stringify(node.nodeId)}) ` +
1192
- `whose choice argument could not be decoded (missing choice_id/chosen_value) — refusing ` +
1193
- `to sign (cannot prove it is the intended ${JSON.stringify(allowedChoiceId)} exercise)`);
1194
- }
1195
- }
1196
- // (3) Exactly one root, and it must resolve to the single allowed exercise.
1197
- if (decoded.roots.length !== 1) {
1198
- throw new PreparedTransferMismatchError(`relay-prepared transaction has ${decoded.roots.length} root nodes — expected exactly one ` +
1199
- `(${JSON.stringify(allowedChoiceId)}) — refusing to sign (possible tampered/compromised ` +
1200
- `relay adding a second root leg)`);
1201
- }
1202
- const byId = new Map();
1203
- for (const node of decoded.nodes) {
1204
- if (byId.has(node.nodeId)) {
1205
- throw new PreparedTransferMismatchError(`relay-prepared transaction has duplicate node id ${JSON.stringify(node.nodeId)} — ` +
1206
- `refusing to sign (ambiguous node graph)`);
1207
- }
1208
- byId.set(node.nodeId, node);
1209
- }
1210
- const rootId = decoded.roots[0];
1211
- const rootNode = byId.get(rootId);
1212
- if (rootNode === undefined) {
1213
- throw new PreparedTransferMismatchError(`relay-prepared transaction root id ${JSON.stringify(rootId)} does not match any node — ` +
1214
- `refusing to sign`);
1215
- }
1216
- if (rootNode.kind !== "exercise" || rootNode.exercise === undefined) {
1217
- throw new PreparedTransferMismatchError(`relay-prepared transaction root node is not a ${JSON.stringify(allowedChoiceId)} exercise ` +
1218
- `(got node type ${JSON.stringify(rootNode.kind ?? "unknown")}) — refusing to sign`);
1219
- }
1220
- if (rootNode.exercise.choiceId !== allowedChoiceId) {
1221
- throw new PreparedTransferMismatchError(`relay-prepared transaction root exercise is ${JSON.stringify(rootNode.exercise.choiceId)} — ` +
1222
- `expected ${JSON.stringify(allowedChoiceId)} — refusing to sign`);
1223
- }
1224
- // (4) No orphans: every node must be reachable from the single root via the
1225
- // children chain. An unreferenced sibling (the extra-leg vector) ⇒ reject.
1226
- const reachable = new Set();
1227
- const stack = [rootId];
1228
- while (stack.length > 0) {
1229
- const id = stack.pop();
1230
- if (reachable.has(id))
1231
- continue;
1232
- reachable.add(id);
1233
- const node = byId.get(id);
1234
- if (node === undefined) {
1235
- throw new PreparedTransferMismatchError(`relay-prepared transaction references child node id ${JSON.stringify(id)} that does not ` +
1236
- `exist — refusing to sign`);
1237
- }
1238
- for (const child of node.children)
1239
- stack.push(child);
1240
- }
1241
- const orphans = decoded.nodes.filter((nd) => !reachable.has(nd.nodeId));
1242
- if (orphans.length > 0) {
1243
- throw new PreparedTransferMismatchError(`relay-prepared transaction contains ${orphans.length} node(s) not reachable from the root ` +
1244
- `(ids ${orphans.map((o) => JSON.stringify(o.nodeId)).join(", ")}) — refusing to sign ` +
1245
- `(possible tampered/compromised relay hiding an extra leg)`);
1246
- }
1247
- return { exercise: rootNode.exercise, rootNodeId: rootId };
1248
- }
1249
- /* ────────────────────────────────────────────────────────────────────────
1250
- * Shared SIGNED-Metadata checks (synchronizer + timing). Every field below is
1251
- * in the proto's "Metadata information that needs to be signed" block, so the
1252
- * agent's single signature authorizes them; verify must constrain the ones that
1253
- * decide WHERE (which synchronizer) and WHEN (validity window) the transfer
1254
- * lands, or a malicious relay can land the agent's signature on a wrong domain
1255
- * or with an already-lapsed / implausibly-skewed validity window.
1256
- * ──────────────────────────────────────────────────────────────────────── */
1257
- /** Pin Metadata.synchronizer_id to caller intent when the caller supplies one.
1258
- * Fail-closed: if pinned but absent/different, refuse. (No pin ⇒ unchanged.) */
1259
- /**
1260
- * The version-serial suffix a participant deterministically appends to the
1261
- * LOGICAL synchronizer id to form the PHYSICAL one it signs into
1262
- * Metadata.synchronizer_id: `::<protocol-version>-<topology-serial>`, e.g. a
1263
- * logical `global-domain::1220<fingerprint>` is signed as
1264
- * `global-domain::1220<fingerprint>::35-2`. STRICT shape: two non-empty decimal
1265
- * runs joined by a single hyphen. (Confirmed on a live TestNet prepared
1266
- * `TransferFactory_Transfer`: prepare REQUIRES the logical id — the participant
1267
- * 400s `INVALID_FIELD synchronizer_id` on the physical form — yet the bytes it
1268
- * returns carry the physical `::35-2` suffix, so an exact-equality pin against
1269
- * the logical caller intent false-rejects a legitimate tx.) */
1270
- const PHYSICAL_SYNCHRONIZER_SUFFIX = /^[0-9]+-[0-9]+$/;
1271
- /**
1272
- * Pin Metadata.synchronizer_id to caller intent. Fail-closed: a different domain
1273
- * is refused. The caller threads the LOGICAL synchronizer id (`<name>::<namespace
1274
- * fingerprint>`) — the value the participant accepts on prepare — but the SIGNED
1275
- * bytes carry the PHYSICAL id (logical + `::<version>-<serial>`). We therefore
1276
- * accept the signed id iff it is EXACTLY the expected logical id, OR the expected
1277
- * id followed by a single `::<version>-<serial>` suffix of the strict
1278
- * physical-synchronizer shape.
1279
- *
1280
- * This is NOT a weakening: the security-relevant identity is the logical id,
1281
- * whose `1220…` is the domain's cryptographic NAMESPACE fingerprint — an attacker
1282
- * cannot forge a different domain that shares it, so anything matching
1283
- * `expected + "::<n>-<m>"` is provably the SAME domain (same namespace owner) at
1284
- * a participant-chosen protocol-version/topology-serial. A genuinely different
1285
- * domain (different name or fingerprint), a non-suffix extension, or a
1286
- * garbage/empty suffix does NOT match and is still rejected.
1287
- */
1288
- function assertSynchronizerMatches(synchronizerId, expected) {
1289
- if (expected === undefined)
1290
- return; // caller did not pin — unchanged behaviour
1291
- if (synchronizerId === undefined) {
1292
- throw new PreparedTransferMismatchError(`relay-prepared transaction has no synchronizer_id but caller intent pins ` +
1293
- `${JSON.stringify(expected)} — refusing to sign`);
1294
- }
1295
- let matches = synchronizerId === expected;
1296
- if (!matches && synchronizerId.startsWith(expected + "::")) {
1297
- // The remainder after the logical id MUST be exactly one physical-suffix
1298
- // segment `<version>-<serial>` — no further `::` segments, no empty/garbage.
1299
- const suffix = synchronizerId.slice(expected.length + 2);
1300
- matches = PHYSICAL_SYNCHRONIZER_SUFFIX.test(suffix);
1301
- }
1302
- if (!matches) {
1303
- throw new PreparedTransferMismatchError(`relay-prepared transaction synchronizer_id is ${JSON.stringify(synchronizerId)} — expected ` +
1304
- `${JSON.stringify(expected)} (optionally with a ::<version>-<serial> physical suffix) — ` +
1305
- `refusing to sign (relay-chosen synchronizer/domain)`);
1306
- }
1307
- }
1308
- /** A generous skew (24h) for sanity-bounding the SIGNED Metadata timing fields.
1309
- * We are not pinning these to a tight window (the participant enforces the real
1310
- * ledger-time window); we only reject values that are clearly wrong — already
1311
- * lapsed, or implausibly far from now — so the agent never blind-signs a command
1312
- * that can never settle or carries a wildly-skewed timestamp. */
1313
- const TIMING_SKEW_MS = 24 * 60 * 60 * 1000;
1314
- /** Microseconds-since-epoch BigInt → milliseconds Number (NaN if not finite). */
1315
- function microsToMs(micros) {
1316
- if (micros === undefined)
1317
- return NaN;
1318
- try {
1319
- return Number(micros / 1000n);
1320
- }
1321
- catch {
1322
- return NaN;
1323
- }
1324
- }
1325
- /**
1326
- * Sanity-bound the SIGNED Metadata timing fields against `nowMs`:
1327
- * - preparation_time must be within ±TIMING_SKEW of now (not wildly skewed);
1328
- * - max_record_time / max_ledger_effective_time must not already be in the past
1329
- * (an expired command can never settle — at best a DoS, at worst replays a
1330
- * stale intent). These are best-effort: absent fields are skipped.
1331
- */
1332
- function assertTimingPlausible(decoded, nowMs) {
1333
- const prep = microsToMs(decoded.preparationTime);
1334
- if (Number.isFinite(prep)) {
1335
- if (prep > nowMs + TIMING_SKEW_MS || prep < nowMs - TIMING_SKEW_MS) {
1336
- throw new PreparedTransferMismatchError(`relay-prepared transaction preparation_time (${new Date(prep).toISOString()}) is ` +
1337
- `implausibly far from now — refusing to sign`);
1338
- }
1339
- }
1340
- const maxRecord = microsToMs(decoded.maxRecordTime);
1341
- if (Number.isFinite(maxRecord) && maxRecord <= nowMs) {
1342
- throw new PreparedTransferMismatchError(`relay-prepared transaction max_record_time (${new Date(maxRecord).toISOString()}) is ` +
1343
- `already in the past — refusing to sign (command could never be recorded)`);
1344
- }
1345
- const maxLet = microsToMs(decoded.maxLedgerEffectiveTime);
1346
- if (Number.isFinite(maxLet) && maxLet <= nowMs) {
1347
- throw new PreparedTransferMismatchError(`relay-prepared transaction max_ledger_effective_time (${new Date(maxLet).toISOString()}) ` +
1348
- `is already in the past — refusing to sign`);
1349
- }
1350
- // min_ledger_effective_time is SIGNED and was DECODED but previously not bounded
1351
- // (an asymmetry vs the fields above). A relay-chosen value implausibly far in
1352
- // the future would make the agent sign a command pinned to an unreachable
1353
- // validity window (it can never become ledger-valid before it expires). Reject
1354
- // it like the others; near-now / absent values pass.
1355
- const minLet = microsToMs(decoded.minLedgerEffectiveTime);
1356
- if (Number.isFinite(minLet) && minLet > nowMs + TIMING_SKEW_MS) {
1357
- throw new PreparedTransferMismatchError(`relay-prepared transaction min_ledger_effective_time (${new Date(minLet).toISOString()}) ` +
1358
- `is implausibly far in the future — refusing to sign (command pinned to an unreachable ` +
1359
- `validity window)`);
1360
- }
1361
- }
1362
- /**
1363
- * Pin WHICH template the validated root exercise runs against. The choice NAME
1364
- * and ARGUMENT are pinned elsewhere; this closes the template/contract-confusion
1365
- * surface by additionally requiring the exercised template's `module:entity`
1366
- * qualified name to equal caller intent (the package id is intentionally not
1367
- * pinned — it changes across upgrades). No pin ⇒ unchanged behaviour.
1368
- */
1369
- function assertTemplateMatches(ex, expected) {
1370
- if (expected === undefined)
1371
- return;
1372
- if (ex.templateQualifiedName === undefined) {
1373
- throw new PreparedTransferMismatchError(`relay-prepared exercise carries no decodable template_id but caller intent pins ` +
1374
- `${JSON.stringify(expected)} — refusing to sign`);
1375
- }
1376
- if (ex.templateQualifiedName !== expected) {
1377
- throw new PreparedTransferMismatchError(`relay-prepared exercise runs against template ${JSON.stringify(ex.templateQualifiedName)} — ` +
1378
- `expected ${JSON.stringify(expected)} — refusing to sign (template/contract confusion)`);
1379
- }
1380
- }
1381
- /**
1382
- * Pin WHICH contract the validated root exercise is exercised on
1383
- * (Exercise.contract_id). Defense-in-depth, OPT-IN: when the caller supplies the
1384
- * exact target cid it built the command against (the factory / EPAR contract id
1385
- * the relay resolved to it), the verifier requires the prepared exercise to
1386
- * target the SAME contract and fails closed otherwise — closing the
1387
- * resolve→prepare TOCTOU where a relay resolves one contract to the agent but
1388
- * prepares the exercise against another. No pin ⇒ unchanged behaviour (the
1389
- * all-nodes party backstop still contains any fund redirect a substituted target
1390
- * could attempt, since the redirect needs a foreign-party consequence).
1391
- */
1392
- function assertContractIdMatches(ex, expected) {
1393
- if (expected === undefined)
1394
- return;
1395
- if (ex.contractId === undefined) {
1396
- throw new PreparedTransferMismatchError(`relay-prepared exercise carries no decodable contract_id but caller intent pins ` +
1397
- `${JSON.stringify(expected)} — refusing to sign`);
1398
- }
1399
- if (ex.contractId !== expected) {
1400
- throw new PreparedTransferMismatchError(`relay-prepared exercise targets contract ${JSON.stringify(ex.contractId)} — ` +
1401
- `expected ${JSON.stringify(expected)} — refusing to sign ` +
1402
- `(relay points the choice at a contract other than the one it resolved)`);
1403
- }
1404
- }
1405
- /**
1406
- * Collect party leaves across the WHOLE signed message, split by position
1407
- * relative to the validated root exercise (`rootNodeId`). Reuses the strong,
1408
- * fail-closed `collectPartyLeaves` primitive on each Value payload, and adds the
1409
- * node-level party-metadata strings + Metadata.input_contracts Create arguments
1410
- * so a party introduced anywhere the agent's signature covers is visible.
1411
- */
1412
- function collectSplitPartyLeaves(decoded, rootNodeId) {
1413
- const rootArg = [];
1414
- const elsewhere = [];
1415
- const nodeValues = [];
1416
- const inputArgs = [];
1417
- for (const node of decoded.nodes) {
1418
- const isRoot = node.nodeId === rootNodeId;
1419
- // A node's VALUE payloads carry ownership (a Create argument names the owner
1420
- // of what it creates), so they are tagged with the node's template and kept
1421
- // OUT of the loose `elsewhere` bucket. For the generic (no role exception)
1422
- // case they are checked with exactly the same rule as `elsewhere`, so this
1423
- // split is behaviour-preserving.
1424
- const tag = node.create?.templateQualifiedName;
1425
- const collectValue = (value) => {
1426
- const parties = [];
1427
- collectPartyLeaves(value, parties, 0);
1428
- if (parties.length > 0)
1429
- nodeValues.push({ templateQualifiedName: tag, parties });
1430
- };
1431
- if (isRoot && node.exercise !== undefined) {
1432
- // The root exercise's chosen_value is the only place the admin/dso may sit
1433
- // at its pinned position; its exercise_result (also in node.values) is a
1434
- // consequence, so treat it as a tagged value payload.
1435
- collectPartyLeaves(node.exercise.chosenValue, rootArg, 0);
1436
- for (const value of node.values) {
1437
- if (value !== node.exercise.chosenValue)
1438
- collectValue(value);
1439
- }
1440
- }
1441
- else {
1442
- for (const value of node.values)
1443
- collectValue(value);
1444
- }
1445
- // Node-level party metadata (authorization/visibility parties) — for ALL
1446
- // nodes including the root — are never a place the relay may introduce an
1447
- // unexpected party, so they go in `elsewhere`.
1448
- for (const p of node.partyMeta)
1449
- elsewhere.push(p);
1450
- }
1451
- // Authenticated input-contract ARGUMENTS, tagged by their template so a
1452
- // protocol role can be recognised at the exact contract that explains it.
1453
- for (const ic of decoded.inputContracts) {
1454
- if (ic.argument === undefined)
1455
- continue;
1456
- const parties = [];
1457
- collectPartyLeaves(ic.argument, parties, 0);
1458
- if (parties.length > 0) {
1459
- inputArgs.push({ templateQualifiedName: ic.templateQualifiedName, parties });
1460
- }
1461
- }
1462
- // Metadata.input_contracts Create signatories/stakeholders/key (also SIGNED via
1463
- // the V2 metadata hasher's disclosed-contract hashNode): authorization
1464
- // metadata, same treatment as node metadata.
1465
- for (const p of decoded.inputContractPartyMeta)
1466
- elsewhere.push(p);
1467
- return { rootArg, elsewhere, nodeValues, inputArgs };
1468
- }
1469
- /**
1470
- * The foreign-party backstop, position-aware so a relay-supplied admin/dso can
1471
- * never widen the recipient set across the whole transaction.
1472
- *
1473
- * @param allowed {sender, receiver, (delegate)} — recipients pinned to intent.
1474
- * @param adminDso the admin/dso value read at its OWN root position (or undefined).
1475
- * @param adminDsoRootMax how many times `adminDso` may legitimately appear in the
1476
- * root chosen_value (cip56: 2 = expectedAdmin + instrumentId.admin;
1477
- * v1: 1 = expectedDso).
1478
- * @param adminDsoTrusted true iff `adminDso` was pinned to an independently-trusted
1479
- * caller value — only then is it safe to value-exclude it
1480
- * OUTSIDE its root position (consequence/meta/input nodes).
1481
- */
1482
- function assertNoForeignParties(leaves, allowed, adminDso, adminDsoRootMax, adminDsoTrusted,
1483
- /** The `provider` of the MATCHED standard TransferPreapproval, when one was
1484
- * proven to belong to this exact transfer (see matchPreapprovalProvider).
1485
- * undefined → no exception at all, and every bucket below behaves exactly as
1486
- * it did before this role existed. */
1487
- preapprovalProvider) {
1488
- const foreign = [];
1489
- /** Templates at which the matched provider legitimately appears INSIDE a
1490
- * payload. Everything else, including any Amulet/holding create, stays
1491
- * foreign — that is the single loss barrier this exception rests on. */
1492
- const providerArgTemplates = new Set([
1493
- PREAPPROVAL_TEMPLATE, // its own provider field (declaration position 2)
1494
- FEATURED_APP_RIGHT_TEMPLATE, // the provider's featured-app right, fetched
1495
- FEATURED_APP_MARKER_TEMPLATE, // the reward marker naming it as beneficiary
1496
- ]);
1497
- const providerAllowedIn = (template) => preapprovalProvider !== undefined &&
1498
- template !== undefined &&
1499
- providerArgTemplates.has(template);
1500
- // Root chosen_value: allow recipients freely; allow adminDso up to its known
1501
- // count at its pinned position(s); a SECOND/extra occurrence (e.g. a smuggled
1502
- // extra recipient leaf that happens to equal the admin/dso value) is foreign.
1503
- let adminDsoBudget = adminDso !== undefined ? adminDsoRootMax : 0;
1504
- for (const p of leaves.rootArg) {
1505
- if (allowed.has(p))
1506
- continue;
1507
- if (adminDso !== undefined && p === adminDso && adminDsoBudget > 0) {
1508
- adminDsoBudget--;
1509
- continue;
1510
- }
1511
- foreign.push(p);
1512
- }
1513
- // Everywhere else (consequence/sibling node Value payloads, ANY node's party
1514
- // metadata, Metadata.input_contracts arguments): allow recipients freely; allow
1515
- // adminDso ONLY when it is independently TRUSTED (caller-pinned to an out-of-band
1516
- // value). A relay-chosen, UNPINNED admin/dso that reappears outside its root
1517
- // position is treated as FOREIGN — this closes the "alias the unpinned
1518
- // admin/dso to an attacker and inject that same value as a consequence /
1519
- // node-metadata / input-contract party" neutralization (the relay controls the
1520
- // unpinned value, so value-excluding it anywhere would whitelist the attacker
1521
- // globally). The honest consequence legitimately carries the real DSO as a
1522
- // payload party + signatory, so a value-moving caller MUST supply a trusted
1523
- // expectedDso/instrumentAdmin (out-of-band; tx.ts plumbs it from
1524
- // CANTON_AGENT_DSO_PARTY) — without it we fail closed here rather than trust a
1525
- // relay-supplied value. The previous no-trusted-pin value-global fallback is
1526
- // REMOVED: it was exactly the residual neutralization an adversary exploits.
1527
- for (const p of leaves.elsewhere) {
1528
- if (allowed.has(p))
1529
- continue;
1530
- if (adminDso !== undefined && adminDsoTrusted && p === adminDso)
1531
- continue;
1532
- // AUTHORIZATION / VISIBILITY metadata only. The matched preapproval provider
1533
- // is a signatory of its own contract and an authorizer of the delivery
1534
- // exercise, so it necessarily appears here. Ownership is NOT expressible in
1535
- // this bucket — it lives in the tagged argument buckets below — so granting
1536
- // the role here cannot create a second money destination.
1537
- if (preapprovalProvider !== undefined && p === preapprovalProvider)
1538
- continue;
1539
- foreign.push(p);
1540
- }
1541
- // VALUE payloads (consequence Create arguments + exercise results). Identical
1542
- // rule to `elsewhere` for everyone EXCEPT the provider, which is admitted only
1543
- // inside the reward marker that names it as beneficiary. Any other create —
1544
- // above all `Splice.Amulet:Amulet` — keeps rejecting it, so the provider can
1545
- // never become an owner of value.
1546
- for (const bucket of leaves.nodeValues) {
1547
- for (const p of bucket.parties) {
1548
- if (allowed.has(p))
1549
- continue;
1550
- if (adminDso !== undefined && adminDsoTrusted && p === adminDso)
1551
- continue;
1552
- if (preapprovalProvider !== undefined &&
1553
- p === preapprovalProvider &&
1554
- providerAllowedIn(bucket.templateQualifiedName)) {
1555
- continue;
1556
- }
1557
- foreign.push(p);
1558
- }
1559
- }
1560
- // Authenticated input-contract ARGUMENTS. Same rule; the provider is admitted
1561
- // only inside the preapproval it provides and its own featured-app right.
1562
- for (const bucket of leaves.inputArgs) {
1563
- for (const p of bucket.parties) {
1564
- if (allowed.has(p))
1565
- continue;
1566
- if (adminDso !== undefined && adminDsoTrusted && p === adminDso)
1567
- continue;
1568
- if (preapprovalProvider !== undefined &&
1569
- p === preapprovalProvider &&
1570
- providerAllowedIn(bucket.templateQualifiedName)) {
1571
- continue;
1572
- }
1573
- foreign.push(p);
1574
- }
1575
- }
1576
- if (foreign.length > 0) {
1577
- const uniq = [...new Set(foreign)];
1578
- throw new PreparedTransferMismatchError(`relay-prepared transaction references unexpected part${uniq.length === 1 ? "y" : "ies"} ` +
1579
- `${uniq.map((f) => JSON.stringify(f)).join(", ")} — refusing to sign ` +
1580
- `(possible tampered/compromised relay redirecting funds)`);
1581
- }
1582
- }
1583
- /* ────────────────────────────────────────────────────────────────────────
1584
- * Standard validator-provided TransferPreapproval.
1585
- *
1586
- * A Splice `TransferPreapproval` is `signatory receiver, provider, dso`. The
1587
- * merchant may create it through its OWN validator, in which case `provider` is
1588
- * the validator operator party — a THIRD party, distinct from sender/receiver.
1589
- * That is the stock shape (it keeps validator renewal automation and the
1590
- * provider's featured-app attribution), so the payer wallet MUST be able to sign
1591
- * such a transfer. Before this, the generic backstop read the provider as an
1592
- * injected recipient and refused; only `provider == receiver` happened to pass.
1593
- *
1594
- * THE PROVIDER IS NEVER TRUSTED. It is read out of relay-supplied bytes, so it
1595
- * is bound as a local PROTOCOL ROLE and only after proving, structurally, that
1596
- * it is the provider of the exact preapproval this transfer delivers through:
1597
- *
1598
- * 1. exactly ONE authenticated input contract is a TransferPreapproval;
1599
- * 2. its `receiver` equals the caller's intended receiver;
1600
- * 3. its `dso` equals the transfer's instrument admin;
1601
- * 4. its signatory set is exactly {receiver, provider, dso};
1602
- * 5. a consequence exercises `TransferPreapproval_Send`/`_SendV2` on the
1603
- * TransferPreapproval template, targeting THAT contract id.
1604
- *
1605
- * The role then buys exactly one thing: the provider stops being "foreign" in
1606
- * authorization metadata and inside the three payloads that structurally must
1607
- * name it. It is still foreign in the root transfer record and in every
1608
- * value-owning position, so it can never become a second money destination.
1609
- *
1610
- * Residual, accepted knowingly: a fabricated-but-self-consistent preapproval can
1611
- * satisfy 1-5 locally, because contract EXISTENCE is proven downstream by the
1612
- * ledger, not here. The outcome of that is a signed transaction the participant
1613
- * refuses to execute — denial and retry, never payer-fund loss, because the
1614
- * money-position rule holds regardless.
1615
- * ──────────────────────────────────────────────────────────────────────── */
1616
- const PREAPPROVAL_TEMPLATE = "Splice.AmuletRules:TransferPreapproval";
1617
- const FEATURED_APP_RIGHT_TEMPLATE = "Splice.Amulet:FeaturedAppRight";
1618
- const FEATURED_APP_MARKER_TEMPLATE = "Splice.Amulet:FeaturedAppActivityMarker";
1619
- /** Delivery choices that consume a TransferPreapproval. `_Send` is the legacy
1620
- * name; the live TransferFactory path uses `_SendV2`. Both are already in
1621
- * TRANSFER_CONSEQUENCE_CHOICES, so this set only identifies the delivery node. */
1622
- const PREAPPROVAL_SEND_CHOICES = new Set([
1623
- "TransferPreapproval_Send",
1624
- "TransferPreapproval_SendV2",
1625
- ]);
1626
- /**
1627
- * Read `TransferPreapproval` by DAML DECLARATION ORDER — [0] dso, [1] receiver,
1628
- * [2] provider — with the same label/position-divergence guard the money fields
1629
- * use, so a relay cannot relabel fields to move the provider slot. Order
1630
- * CONFIRMED against a real MainNet input contract (see src/__fixtures__/).
1631
- * Returns undefined when the record does not have that exact shape; the caller
1632
- * then grants no role and the generic backstop refuses as before.
1633
- */
1634
- function extractTransferPreapproval(argument) {
1635
- let entries;
1636
- try {
1637
- entries = recordEntries(argument);
1638
- }
1639
- catch {
1640
- return undefined;
1641
- }
1642
- const dsoE = entryByDeclOrder(entries, 0, "dso");
1643
- const receiverE = entryByDeclOrder(entries, 1, "receiver");
1644
- const providerE = entryByDeclOrder(entries, 2, "provider");
1645
- if (!dsoE || !receiverE || !providerE)
1646
- return undefined;
1647
- const dso = leafOf(dsoE.value);
1648
- const receiver = leafOf(receiverE.value);
1649
- const provider = leafOf(providerE.value);
1650
- if (dso?.kind !== "party" || receiver?.kind !== "party" || provider?.kind !== "party") {
1651
- return undefined;
1652
- }
1653
- return { dso: dso.value, receiver: receiver.value, provider: provider.value };
1654
- }
1655
- /**
1656
- * Prove that this transfer delivers through a standard TransferPreapproval and
1657
- * return ITS provider, or undefined when nothing may be granted. See the block
1658
- * comment above for the five binding rules and the threat argument.
1659
- *
1660
- * Deliberately returns undefined (no role, generic backstop applies) rather than
1661
- * throwing: a transfer that never touches a preapproval, or an ambiguous set of
1662
- * them, must keep the pre-existing behaviour exactly.
1663
- */
1664
- function matchPreapprovalProvider(decoded, expectedReceiver, instrumentAdmin) {
1665
- // 1. EXACTLY one authenticated preapproval input. Zero → nothing to grant.
1666
- // Two or more → ambiguous, and picking one would let a relay pair an honest
1667
- // preapproval with a decoy whose provider it controls. Refuse to grant.
1668
- const candidates = decoded.inputContracts.filter((ic) => ic.templateQualifiedName === PREAPPROVAL_TEMPLATE);
1669
- if (candidates.length !== 1)
1670
- return undefined;
1671
- const ic = candidates[0];
1672
- if (ic.argument === undefined || ic.contractId === undefined)
1673
- return undefined;
1674
- const pre = extractTransferPreapproval(ic.argument);
1675
- if (pre === undefined)
1676
- return undefined;
1677
- // 2 + 3. The preapproval must be FOR the payment we intend: our receiver, our
1678
- // instrument admin. A preapproval belonging to anyone else grants
1679
- // nothing, so a provider harvested from an unrelated contract is inert.
1680
- if (pre.receiver !== expectedReceiver)
1681
- return undefined;
1682
- if (pre.dso !== instrumentAdmin)
1683
- return undefined;
1684
- // 4. Signatories must be exactly the protocol trio. A relay that appends an
1685
- // extra signatory is not presenting a stock preapproval.
1686
- const sigs = new Set(ic.signatories);
1687
- if (sigs.size !== 3)
1688
- return undefined;
1689
- for (const p of [pre.dso, pre.receiver, pre.provider]) {
1690
- if (!sigs.has(p))
1691
- return undefined;
1692
- }
1693
- // The provider must never be aliased to a money party — that would smuggle a
1694
- // money party into the role exception and exempt it everywhere.
1695
- if (pre.provider === pre.receiver && pre.provider === pre.dso)
1696
- return undefined;
1697
- // 5. The delivery consequence must exercise the preapproval template on THAT
1698
- // contract id. This binds the role to the node that actually spends it,
1699
- // rather than to a contract merely present in the input set.
1700
- const delivered = decoded.nodes.some((n) => n.exercise !== undefined &&
1701
- PREAPPROVAL_SEND_CHOICES.has(n.exercise.choiceId) &&
1702
- n.exercise.templateQualifiedName === PREAPPROVAL_TEMPLATE &&
1703
- n.exercise.contractId === ic.contractId);
1704
- if (!delivered)
1705
- return undefined;
1706
- return pre.provider;
1707
- }
1708
- /**
1709
- * Cross-check the authoritative submitter (Metadata.submitter_info.act_as):
1710
- * REQUIRE a non-empty act_as whose only party is the sender. The non-empty
1711
- * requirement closes GAP A3 (an empty act_as previously skipped this check
1712
- * silently). Called by both arms AFTER the money-critical field comparison so a
1713
- * consistent-attacker transaction surfaces as a field mismatch first.
1714
- */
1715
- function assertActAsIsSender(actAs, sender) {
1716
- if (actAs.length === 0) {
1717
- throw new PreparedTransferMismatchError("relay-prepared transaction has an empty act_as (authoritative submitter) — " +
1718
- "refusing to sign (the submitter must be positively proven to be the agent)");
1719
- }
1720
- const unexpected = actAs.filter((p) => p !== sender);
1721
- if (unexpected.length > 0 || !actAs.includes(sender)) {
1722
- throw new PreparedTransferMismatchError(`relay-prepared transaction acts as ${JSON.stringify(actAs)} — expected only ` +
1723
- `${JSON.stringify(sender)} — refusing to sign`);
1724
- }
1725
- }
1726
- /**
1727
- * Read a money-critical record field BY ITS DAML DECLARATION-ORDER POSITION,
1728
- * cross-checking labels — the fix for the label-vs-positional binding divergence
1729
- * (amount inflation / receiver swap via relabeled/reordered fields).
1730
- *
1731
- * WHY POSITIONAL: a Daml-LF record is POSITIONAL. The participant binds the
1732
- * choice argument by the choice type's field ORDER; the wire `RecordField.label`
1733
- * is advisory. A reader that prefers LABELS can be made to diverge from the
1734
- * engine: a malicious relay places the honest value at a field LABELED "amount"
1735
- * but at a wire position the engine does NOT read as amount, and an INFLATED
1736
- * Numeric at the wire position the engine binds as amount (under a junk label) —
1737
- * a by-label read sees the honest decoy and passes while the engine moves the
1738
- * inflated amount. We therefore read the field at its declaration-order
1739
- * `position` (what the engine reads) and additionally FAIL CLOSED on any
1740
- * label/position inconsistency:
1741
- * (a) the entry AT `position`, if labelled, must carry exactly `label`; and
1742
- * (b) NO OTHER entry may carry `label` (a decoy field re-using a money-critical
1743
- * label at the wrong position).
1744
- * Honest encodings — fully labelled in declaration order, OR label-free
1745
- * (normalized) — both pass; only a label/position-divergent record is rejected.
1746
- * `expectedAt` lets the caller reject a field that the engine would bind at a
1747
- * position the record does not even have (arity check for that field).
1748
- */
1749
- function entryByDeclOrder(entries, position, label) {
1750
- // (b) a money-critical label may appear at most once, and only at its own
1751
- // declaration-order position. A decoy field re-using the label elsewhere is a
1752
- // divergence attempt — fail closed.
1753
- for (let i = 0; i < entries.length; i++) {
1754
- if (i !== position && entries[i]?.label === label) {
1755
- throw new PreparedDecodeError(`record field labelled ${JSON.stringify(label)} appears at position ${i} but the Daml ` +
1756
- `declaration order binds it at position ${position} — refusing to sign ` +
1757
- `(label/position divergence, possible amount/receiver tamper)`);
1758
- }
1759
- }
1760
- const e = entries[position];
1761
- if (e === undefined)
1762
- return undefined;
1763
- // (a) the entry the engine binds at `position`, if labelled, must be THIS field.
1764
- if (e.label !== "" && e.label !== label) {
1765
- throw new PreparedDecodeError(`record field at Daml declaration position ${position} is labelled ${JSON.stringify(e.label)} ` +
1766
- `but ${JSON.stringify(label)} is expected there — refusing to sign ` +
1767
- `(label/position divergence, possible amount/receiver tamper)`);
1768
- }
1769
- return e;
1770
- }
1771
- /** Read instrumentId {admin, id} from a record entry that is itself a record.
1772
- * Declaration order: [0] admin:Party [1] id:Text. Read positionally with the
1773
- * same label/position-divergence guard as the transfer fields. */
1774
- function readInstrument(value) {
1775
- const entries = recordEntries(value);
1776
- const adminE = entryByDeclOrder(entries, 0, "admin");
1777
- const idE = entryByDeclOrder(entries, 1, "id");
1778
- const adminLeaf = adminE ? leafOf(adminE.value) : undefined;
1779
- const idLeaf = idE ? leafOf(idE.value) : undefined;
1780
- if (!adminLeaf || adminLeaf.kind !== "party") {
1781
- throw new PreparedDecodeError("instrumentId.admin is not a party");
1782
- }
1783
- if (!idLeaf || idLeaf.kind !== "text") {
1784
- throw new PreparedDecodeError("instrumentId.id is not text");
1785
- }
1786
- return { admin: adminLeaf.value, id: idLeaf.value };
1787
- }
1788
- /**
1789
- * Extract the transfer body (sender/receiver/amount/instrument) from a
1790
- * TransferFactory_Transfer choice argument, BY TYPE at its structural position.
1791
- */
1792
- export function extractTransfer(chosenValue) {
1793
- const top = recordEntries(chosenValue);
1794
- // Locate the `transfer` sub-record STRICTLY at its Daml declaration position.
1795
- // The outer choiceArgument is
1796
- // [0]expectedAdmin:Party [1]transfer:Record [2]extraArgs:Record
1797
- // so the participant binds `transfer` at declaration position 1, REGARDLESS of
1798
- // labels (a Daml-LF record is positional; the wire `RecordField.label` is
1799
- // advisory). We therefore read position 1 and ONLY position 1, with the same
1800
- // label/position-divergence guard the money fields use.
1801
- //
1802
- // We deliberately do NOT fall back to a shape-based search ("find the first
1803
- // record that looks like a transfer"): that override was a NON-positional read
1804
- // that a malicious relay could weaponize. By type-diverging ONE field of the
1805
- // engine's real position-1 transfer record (e.g. sender as Optional(Party) or
1806
- // receiver as List(Party)) the relay made it fail the shape heuristic, so the
1807
- // search returned a relay-planted, well-typed DECOY transfer record at a
1808
- // DIFFERENT outer position carrying the honest amount — while the engine bound
1809
- // the inflated amount from position 1. The verifier compared the decoy and
1810
- // passed. Reading position 1 directly closes that divergence: the entry the
1811
- // engine binds as `transfer` is the entry we validate, and a type-malformed
1812
- // transfer record is itself a tamper signal — it fails the per-field type
1813
- // checks below and we refuse to sign rather than search elsewhere.
1814
- const transferVal = entryByDeclOrder(top, 1, "transfer")?.value;
1815
- if (!transferVal)
1816
- throw new PreparedDecodeError("could not locate transfer record in choice argument");
1817
- const t = recordEntries(transferVal);
1818
- // Read each money-critical field at its DAML DECLARATION-ORDER POSITION (what
1819
- // the participant binds), NOT by label — and fail closed on any label/position
1820
- // divergence (the amount-inflation / receiver-swap vector). Declaration order:
1821
- // [0] sender:Party [1] receiver:Party [2] amount:Numeric [3] instrumentId:Record
1822
- // (trailing requestedAt/executeBefore/inputHoldingCids/meta are not money-
1823
- // critical here; party leaves in them are still covered by the backstop).
1824
- //
1825
- // MEMO (merchant memo enforcement): the relay stamps the merchant-required memo
1826
- // into transfer.meta as `x402.memo` — a TextMap *string* value. It is
1827
- // deliberately NOT pinned by verify-before-sign: extractTransfer reads only
1828
- // positions 0–3, and the foreign-party backstop (collectPartyLeaves →
1829
- // assertNoForeignParties) scans PARTY leaves only, so a text memo value is
1830
- // tolerated and does not alter the transaction STRUCTURE (the meta TextMap is
1831
- // always present — empty today — so this only adds an entry inside it). This is
1832
- // safe by design: the memo is not money-critical, so a relay that alters or
1833
- // drops it can at worst make the MERCHANT's server-side /verify reject the
1834
- // payment (invalid_exact_canton_memo_mismatch) — it can never redirect funds.
1835
- // Pinning it here would only trade that harmless reject for a client-side
1836
- // refuse-to-sign, so we leave the fail-closed money scanner unchanged.
1837
- const senderE = entryByDeclOrder(t, 0, "sender");
1838
- const receiverE = entryByDeclOrder(t, 1, "receiver");
1839
- const amountE = entryByDeclOrder(t, 2, "amount");
1840
- const instrE = entryByDeclOrder(t, 3, "instrumentId");
1841
- const sender = senderE ? leafOf(senderE.value) : undefined;
1842
- const receiver = receiverE ? leafOf(receiverE.value) : undefined;
1843
- const amount = amountE ? leafOf(amountE.value) : undefined;
1844
- const instrumentVal = instrE?.value;
1845
- if (!sender || sender.kind !== "party")
1846
- throw new PreparedDecodeError("transfer.sender is not a party");
1847
- if (!receiver || receiver.kind !== "party")
1848
- throw new PreparedDecodeError("transfer.receiver is not a party");
1849
- if (!amount || amount.kind !== "numeric")
1850
- throw new PreparedDecodeError("transfer.amount is not numeric");
1851
- if (!instrumentVal)
1852
- throw new PreparedDecodeError("transfer.instrumentId missing");
1853
- const instrument = readInstrument(instrumentVal);
1854
- return {
1855
- sender: sender.value,
1856
- receiver: receiver.value,
1857
- amount: amount.value,
1858
- instrumentAdmin: instrument.admin,
1859
- instrumentId: instrument.id,
1860
- };
1861
- }
1862
- export class PreparedDecodeError extends Error {
1863
- constructor(message) {
1864
- super(`preparedTransaction: ${message}`);
1865
- this.name = "PreparedDecodeError";
1866
- }
1867
- }
1868
- export class PreparedTransferMismatchError extends Error {
1869
- constructor(message) {
1870
- super(message);
1871
- this.name = "PreparedTransferMismatchError";
1872
- }
1873
- }
1874
- /**
1875
- * Assert the relay-returned `preparedTransaction` encodes EXACTLY the transfer
1876
- * the agent intended. Throws `PreparedTransferMismatchError` on any mismatch
1877
- * and `PreparedDecodeError` if the bytes are not a decodable PreparedTransaction
1878
- * carrying a single transfer exercise. Call this BEFORE signing `hash`.
1879
- *
1880
- * Fail-closed: anything we cannot positively prove matches the intent throws.
1881
- */
1882
- /** Choices the honest `TransferFactory_Transfer` consequence subtree carries —
1883
- * verified against the live participant's prepared transfer: `Archive` of the
1884
- * consumed input Amulet(s), and — when the RECEIVER holds a TransferPreapproval
1885
- * — `TransferPreapproval_Send`/`_SendV2` which delivers the amulet to the
1886
- * receiver instead of locking it in a TransferInstruction. Permitted ONLY as
1887
- * reachable consequences of the single Transfer root (never as a second root /
1888
- * orphan). sender/receiver/amount/admin stay pinned by extractTransfer and the
1889
- * all-nodes foreign-party backstop still runs, so an injected redirect is still
1890
- * refused. (Mirrors ACCEPT_CONSEQUENCE_CHOICES on the claim path.) */
1891
- /** `EventLog_HoldingsChange` — introduced by splice-amulet 0.1.21, which Canton
1892
- * MainNet adopted 2026-07-28. It is exercised UNCONDITIONALLY on every amulet
1893
- * transfer on the external-party path, so leaving it out rejects every payment
1894
- * (the outage of 2026-08-01/02).
1895
- *
1896
- * It is declared on the INTERFACE `EventLog`
1897
- * (splice-api-token-transfer-events-v2), whose body is virtual: the implementing
1898
- * package decides what runs. Allowlisting the bare NAME would therefore let any
1899
- * package that implements the same interface run arbitrary code — including
1900
- * consuming effects on children, which surface as the already-allowed `Archive`.
1901
- * So we pin the templates whose implementation we have actually inspected: both
1902
- * route to `eventLog_holdingsChangeDefaultImpl dso`, which only asserts
1903
- * `admin == dso` and returns an empty record — no create, archive, lock or burn.
1904
- *
1905
- * Templates measured on a real MainNet prepared accept (26,668 bytes, captured
1906
- * 2026-08-02 via e2e/capture-accept-bytes.mjs): ExternalPartyConfigState twice
1907
- * as a DIRECT child of the root, AmuletEventLog once as a GRANDCHILD under
1908
- * LockedAmulet_UnlockV2. Depth therefore varies — never pin it. The node's own
1909
- * `amount`/`otherside` fields are NOT validated by the choice body, so amount,
1910
- * receiver and instrument stay pinned exclusively from the root's chosen value,
1911
- * and the foreign-party backstop still scans this node's leaves. */
1912
- const EVENT_LOG_HOLDINGS_CHANGE = {
1913
- choiceId: "EventLog_HoldingsChange",
1914
- templates: [
1915
- "Splice.ExternalPartyConfigState:ExternalPartyConfigState",
1916
- "Splice.AmuletEventLog:AmuletEventLog",
1917
- ],
1918
- };
1919
- const TRANSFER_CONSEQUENCE_CHOICES = [
1920
- "Archive",
1921
- "TransferPreapproval_Send",
1922
- "TransferPreapproval_SendV2",
1923
- EVENT_LOG_HOLDINGS_CHANGE,
1924
- ];
1925
- /**
1926
- * Canonicalize a CC amount decimal string to a fixed 10-fractional-digit form
1927
- * for VALUE-equality comparison. Canton encodes amounts at 10 decimals
1928
- * ("0.0200000000"); a caller (e.g. `withdraw --amount 0.02`) may pass a short
1929
- * form, so comparing the raw strings would spuriously fail verify-before-sign
1930
- * even though the numeric value is identical. Pure string math (no float) so
1931
- * large amounts keep full precision. A non-numeric input is returned as-is, so
1932
- * it still mismatches (fail-closed).
1933
- *
1934
- * UNIT-BY-SCHEME invariant: BOTH sides of every amount compare here are
1935
- * on-ledger Daml **Decimals** — `t.amount` is decoded from the prepared tx's
1936
- * `Value.numeric` (always a ledger Decimal) and `expect.amount` is the caller's
1937
- * INTENDED ledger Decimal (the relay/agent path sources `opts.amount` as the
1938
- * Decimal, never the x402 wire atomic value). `canonicalAmount` therefore only
1939
- * pads Decimals; it must NEVER be fed an atomic integer (an atomic "1" would
1940
- * canonicalize to "1.0000000000" and silently mis-compare against the ledger
1941
- * "0.0000000001"). If a future caller ever sources `expect.amount` from an
1942
- * atomic-scheme wire, it MUST first convert via
1943
- * `wireAmountToLedgerDecimal(scheme, amount)` from @ftptech/x402-canton-core.
1944
- */
1945
- export function canonicalAmount(raw) {
1946
- const m = String(raw).trim().match(/^(\d+)(?:\.(\d*))?$/);
1947
- if (!m)
1948
- return raw;
1949
- const frac = m[2] ?? "";
1950
- // Pad SHORT fractions up to 10 ("0.02" -> "0.0200000000"). NEVER truncate a
1951
- // longer fraction — that would hide extra precision (e.g. "1.00000000001"),
1952
- // letting a relay inflate the amount past the 10-decimal canon undetected. A
1953
- // >10-digit fraction is kept verbatim so it still mismatches the canonical
1954
- // intent (fail-closed). Value-equality only for the legit short-form case.
1955
- return `${m[1]}.${frac.length < 10 ? frac.padEnd(10, "0") : frac}`;
1956
- }
1957
- export function assertPreparedTransferMatches(preparedTransactionB64, expect) {
1958
- const decoded = decodePrepared(preparedTransactionB64);
1959
- // Node-traversal invariant (shared with the v1 arm): exactly one allowed ROOT
1960
- // exercise & no other root; EVERY node recognized (no node hidden under an
1961
- // unknown version/type); EXACTLY ONE root that IS the single allowed
1962
- // TransferFactory_Transfer exercise; no orphan/extra-leg node. The honest
1963
- // settlement consequences (Archive of the input + preapproval delivery) are
1964
- // whitelisted as CONSEQUENCES only. Returns the validated root exercise + its
1965
- // node id (for the position-aware backstop).
1966
- const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, TRANSFER_CHOICE, "transfer", TRANSFER_CONSEQUENCE_CHOICES);
1967
- const t = extractTransfer(ex.chosenValue);
1968
- const mismatches = [];
1969
- if (t.sender !== expect.sender) {
1970
- mismatches.push(`sender (got ${JSON.stringify(t.sender)}, intended ${JSON.stringify(expect.sender)})`);
1971
- }
1972
- if (t.receiver !== expect.receiver) {
1973
- mismatches.push(`receiver (got ${JSON.stringify(t.receiver)}, intended ${JSON.stringify(expect.receiver)})`);
1974
- }
1975
- if (canonicalAmount(t.amount) !== canonicalAmount(expect.amount)) {
1976
- mismatches.push(`amount (got ${JSON.stringify(t.amount)}, intended ${JSON.stringify(expect.amount)})`);
1977
- }
1978
- if (t.instrumentId !== expect.instrumentId) {
1979
- mismatches.push(`instrumentId.id (got ${JSON.stringify(t.instrumentId)}, intended ${JSON.stringify(expect.instrumentId)})`);
1980
- }
1981
- // Only pin the admin if the caller supplied an independently-trusted value.
1982
- if (expect.instrumentAdmin !== undefined && t.instrumentAdmin !== expect.instrumentAdmin) {
1983
- mismatches.push(`instrumentId.admin (got ${JSON.stringify(t.instrumentAdmin)}, intended ${JSON.stringify(expect.instrumentAdmin)})`);
1984
- }
1985
- if (mismatches.length > 0) {
1986
- throw new PreparedTransferMismatchError(`relay-prepared transfer does not match intent: ${mismatches.join("; ")} — ` +
1987
- `refusing to sign (possible tampered/compromised relay redirecting funds)`);
1988
- }
1989
- // The admin/dso must never be aliased to a money role — otherwise a relay could
1990
- // set the (unpinned) admin to the receiver/sender and have the backstop exempt
1991
- // a money party. Reject up front.
1992
- if (t.instrumentAdmin === expect.sender ||
1993
- t.instrumentAdmin === expect.receiver) {
1994
- throw new PreparedTransferMismatchError(`relay-prepared transfer instrumentId.admin ${JSON.stringify(t.instrumentAdmin)} equals a ` +
1995
- `transfer party (sender/receiver) — refusing to sign`);
1996
- }
1997
- // Pin WHICH template + WHICH contract the choice runs against
1998
- // (template/contract-confusion + resolve→prepare TOCTOU) and the SIGNED
1999
- // synchronizer + timing metadata (relay-chosen domain / validity window). All
2000
- // no-ops unless the caller supplies the corresponding intent.
2001
- assertTemplateMatches(ex, expect.templateQualifiedName);
2002
- assertContractIdMatches(ex, expect.expectedContractId);
2003
- assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
2004
- assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2005
- // Cross-check the authoritative submitter: REQUIRE a non-empty act_as whose
2006
- // only party is the sender (closes GAP A3 — an empty act_as no longer skips
2007
- // this). Placed after the field comparison so a consistent-attacker tx
2008
- // surfaces as a field mismatch first.
2009
- assertActAsIsSender(decoded.actAs, expect.sender);
2010
- // Position-aware foreign-party backstop over the WHOLE signed message (all node
2011
- // Value payloads + node-level party metadata + Metadata.input_contracts): no
2012
- // party other than {sender, receiver} may appear, EXCEPT the instrument admin
2013
- // (DSO) at its known root positions (expectedAdmin + instrumentId.admin = 2).
2014
- // The admin is value-excluded OUTSIDE its root position ONLY when pinned to an
2015
- // independently-trusted value (expect.instrumentAdmin) — so a relay-chosen,
2016
- // unpinned admin can no longer be aliased to the attacker and smuggled in as a
2017
- // consequence/metadata recipient.
2018
- const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2019
- // Standard validator-provided preapprovals put a THIRD party (the provider)
2020
- // into authorization metadata and three specific payloads. Prove it is the
2021
- // provider of the exact preapproval this transfer delivers through, then admit
2022
- // it ONLY at those positions. undefined → no exception, byte-identical to the
2023
- // pre-existing behaviour (self-provider merchants take this path unchanged,
2024
- // because there the provider IS the receiver and was never foreign).
2025
- const preapprovalProvider = matchPreapprovalProvider(decoded, expect.receiver, t.instrumentAdmin);
2026
- assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver]), t.instrumentAdmin, 2 /* expectedAdmin + instrumentId.admin */, expect.instrumentAdmin !== undefined /* trusted iff caller pinned it */, preapprovalProvider);
2027
- }
2028
- /* ════════════════════════════════════════════════════════════════════════
2029
- * claim path — verify-before-sign for `TransferInstruction_Accept`.
2030
- *
2031
- * THREAT MODEL: the relay is MALICIOUS. `claimAll` accepts INCOMING transfers
2032
- * (funds the agent is RECEIVING). Previously it passed NO verify and NO hash
2033
- * binding, so the agent blind-signed whatever the relay returned — a malicious
2034
- * relay could return an OUTBOUND drain (a CreateTransferCommand / TransferFactory
2035
- * _Transfer sending the agent's balance to an attacker) with the matching honest
2036
- * hash, and the agent would sign it. A single Ed25519 signature authorizes the
2037
- * WHOLE transaction, so the claim path must be gated like every value-moving
2038
- * path.
2039
- *
2040
- * The decisive structural property of a legitimate claim: its SINGLE ROOT
2041
- * exercise is `TransferInstruction_Accept`, and there is NO OTHER exercise of any
2042
- * choice — in particular no outbound transfer/create-transfer-command leg. We
2043
- * reuse the shared invariant (`assertSingleAllowedRootExercise` with
2044
- * allowedChoiceId = TransferInstruction_Accept), which fails closed on a drain
2045
- * (its root is an outbound choice ≠ the accept) and on any extra leg / hidden
2046
- * node, and we require the authoritative submitter (act_as) to be exactly the
2047
- * agent. We deliberately do NOT pin a foreign-party allowlist here: an accept of
2048
- * an incoming transfer legitimately references its counterparties (the sender
2049
- * paying the agent, the DSO, etc.) — the root-choice + act_as + hash-binding gate
2050
- * is what makes a drain unsignable, not a recipient allowlist.
2051
- * ════════════════════════════════════════════════════════════════════════ */
2052
- /** The accept choice on a Splice TransferInstruction (funds-in / claim path). */
2053
- const ACCEPT_CHOICE = "TransferInstruction_Accept";
2054
- /** Choices the honest TransferInstruction_Accept (claim) consequence subtree
2055
- * carries — verified against the live participant's prepared accept: Archive of
2056
- * the consumed TransferInstruction, and LockedAmulet_UnlockV2 releasing the
2057
- * locked amulet to the receiver. Permitted ONLY as reachable consequences of
2058
- * the single Accept root (never as a second root / orphan), and never extended
2059
- * to a value-moving choice — so an injected outbound drain is still refused. */
2060
- const ACCEPT_CONSEQUENCE_CHOICES = [
2061
- "Archive",
2062
- "LockedAmulet_UnlockV2",
2063
- EVENT_LOG_HOLDINGS_CHANGE,
2064
- ];
2065
- /**
2066
- * Assert the relay-returned `preparedTransaction` for the claim path encodes
2067
- * EXACTLY a single `TransferInstruction_Accept` exercise submitted by the agent —
2068
- * and is NOT an outbound transfer/create-transfer-command drain. Throws
2069
- * `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError` if
2070
- * the bytes are not a decodable PreparedTransaction. Call BEFORE signing the
2071
- * hash. Fail-closed: anything not positively proven to be an inbound accept by
2072
- * the agent throws.
2073
- */
2074
- export function assertPreparedAcceptMatches(preparedTransactionB64, expect) {
2075
- const decoded = decodePrepared(preparedTransactionB64);
2076
- // Shared node-traversal invariant: EXACTLY ONE root and it IS the
2077
- // TransferInstruction_Accept exercise (actAs the agent); EVERY node recognized;
2078
- // no orphan/extra-leg node. The accept's honest settlement consequences (Archive
2079
- // of the consumed TransferInstruction + LockedAmulet_UnlockV2 releasing the
2080
- // locked amulet to the agent) are whitelisted as CONSEQUENCES only — still bound
2081
- // to the single Accept root by the reachability check. A relay-injected outbound
2082
- // drain (CreateTransferCommand / TransferFactory_Transfer / a second root) is
2083
- // still refused: it is neither the root choice nor a whitelisted consequence.
2084
- assertSingleAllowedRootExercise(decoded, ACCEPT_CHOICE, ACCEPT_CHOICE, ACCEPT_CONSEQUENCE_CHOICES);
2085
- // SIGNED timing metadata sanity (relay-chosen validity window).
2086
- assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2087
- // Cross-check the authoritative submitter: REQUIRE a non-empty act_as whose
2088
- // only party is the agent — the accept must be submitted under the agent's own
2089
- // authority, never a third party's.
2090
- assertActAsIsSender(decoded.actAs, expect.selfParty);
2091
- }
107
+ /* The validator moved to @ftptech/x402-canton-core (prepared-transfer.ts) so the
108
+ * facilitator can reuse it for inline payloads. Re-exported here so every
109
+ * existing import of ./verify-prepared.js keeps working unchanged. */
110
+ export { decodePrepared, extractTransfer, canonicalAmount, assertPreparedTransferMatches, assertPreparedAcceptMatches, PreparedDecodeError, PreparedTransferMismatchError, } from "@ftptech/x402-canton-core";
111
+ import { decodePrepared, PreparedDecodeError, PreparedTransferMismatchError, } from "@ftptech/x402-canton-core";
2092
112
  /** Thrown when a supplied `recomputeHash` cannot faithfully encode the bytes. */
2093
113
  export class PreparedHashUnavailableError extends Error {
2094
114
  constructor(message) {