@ftptech/canton-agent-wallet 0.1.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.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +134 -0
  3. package/dist/canton-hash.d.ts +61 -0
  4. package/dist/canton-hash.d.ts.map +1 -0
  5. package/dist/canton-hash.js +108 -0
  6. package/dist/canton-hash.js.map +1 -0
  7. package/dist/cli-args.d.ts +31 -0
  8. package/dist/cli-args.d.ts.map +1 -0
  9. package/dist/cli-args.js +56 -0
  10. package/dist/cli-args.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +123 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/hash-binding.d.ts +40 -0
  16. package/dist/hash-binding.d.ts.map +1 -0
  17. package/dist/hash-binding.js +20 -0
  18. package/dist/hash-binding.js.map +1 -0
  19. package/dist/index.d.ts +13 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +13 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/keys.d.ts +26 -0
  24. package/dist/keys.d.ts.map +1 -0
  25. package/dist/keys.js +38 -0
  26. package/dist/keys.js.map +1 -0
  27. package/dist/onboard.d.ts +12 -0
  28. package/dist/onboard.d.ts.map +1 -0
  29. package/dist/onboard.js +152 -0
  30. package/dist/onboard.js.map +1 -0
  31. package/dist/pay.d.ts +16 -0
  32. package/dist/pay.d.ts.map +1 -0
  33. package/dist/pay.js +19 -0
  34. package/dist/pay.js.map +1 -0
  35. package/dist/relay-client.d.ts +128 -0
  36. package/dist/relay-client.d.ts.map +1 -0
  37. package/dist/relay-client.js +67 -0
  38. package/dist/relay-client.js.map +1 -0
  39. package/dist/relay-signer.d.ts +33 -0
  40. package/dist/relay-signer.d.ts.map +1 -0
  41. package/dist/relay-signer.js +44 -0
  42. package/dist/relay-signer.js.map +1 -0
  43. package/dist/store.d.ts +15 -0
  44. package/dist/store.d.ts.map +1 -0
  45. package/dist/store.js +33 -0
  46. package/dist/store.js.map +1 -0
  47. package/dist/trusted-dso.d.ts +33 -0
  48. package/dist/trusted-dso.d.ts.map +1 -0
  49. package/dist/trusted-dso.js +36 -0
  50. package/dist/trusted-dso.js.map +1 -0
  51. package/dist/tx.d.ts +102 -0
  52. package/dist/tx.d.ts.map +1 -0
  53. package/dist/tx.js +328 -0
  54. package/dist/tx.js.map +1 -0
  55. package/dist/verify-prepared.d.ts +361 -0
  56. package/dist/verify-prepared.d.ts.map +1 -0
  57. package/dist/verify-prepared.js +2235 -0
  58. package/dist/verify-prepared.js.map +1 -0
  59. package/dist/withdraw.d.ts +18 -0
  60. package/dist/withdraw.d.ts.map +1 -0
  61. package/dist/withdraw.js +31 -0
  62. package/dist/withdraw.js.map +1 -0
  63. package/package.json +33 -0
@@ -0,0 +1,2235 @@
1
+ /**
2
+ * verify-before-sign — defend the agent's self-custody key against a malicious
3
+ * or compromised relay.
4
+ *
5
+ * THREAT MODEL
6
+ * ------------
7
+ * The agent builds a `TransferFactory_Transfer` (sender / receiver / amount /
8
+ * instrumentId) and asks the relay to PREPARE it. The relay returns an opaque
9
+ * `preparedTransaction` (a base64 Canton `PreparedTransaction` protobuf) plus
10
+ * the `hash` the agent is expected to sign. The agent then signs that hash with
11
+ * its own key. README/docs promise self-custody: "the relay never holds the key
12
+ * and cannot move the agent's funds". But if the agent signs the relay-returned
13
+ * hash BLINDLY, a compromised relay can prepare a DIFFERENT transfer (swap the
14
+ * receiver, inflate the amount, change the instrument) and hand back its hash —
15
+ * the agent's signature would then authorize the attacker's transfer. Blind
16
+ * signing breaks the self-custody guarantee.
17
+ *
18
+ * APPROACH (STRUCTURAL decode, schema-pinned, zero-dependency)
19
+ * ------------------------------------------------------------
20
+ * We do NOT substring-scan the blob. We decode the Canton `PreparedTransaction`
21
+ * protobuf STRUCTURALLY, descending by *field number* through exactly the path
22
+ * Canton serializes (the field numbers are taken from the published Ledger API
23
+ * `.proto`s — see FIELD MAP below). We reach the `Exercise` node that runs the
24
+ * transfer choice, read its `chosen_value` (the choice-argument `Value` tree),
25
+ * and pull the transfer's sender / receiver / amount / instrumentId.id from
26
+ * their REAL typed positions:
27
+ *
28
+ * - a recipient/sender is a Daml `Party`, serialized as `Value.party`
29
+ * (oneof tag 7) — never `Value.text`. An attacker cannot disguise a
30
+ * redirect as a text field.
31
+ * - the amount is a Daml `Numeric`, serialized as `Value.numeric` (oneof
32
+ * tag 6) — at the transfer record's amount position, not "somewhere".
33
+ *
34
+ * We then compare the EXTRACTED values to the caller's INTENT (sender ==
35
+ * wallet.party, receiver == the intended payTo, amount == the EXACT requested
36
+ * amount, instrumentId.id == the caller-known instrument). Crucially we NEVER
37
+ * trust a value taken from the relay's prepared bytes (or its resolve response)
38
+ * as a whitelist — the recipient is pinned to caller intent by exact equality
39
+ * at its structural position, so a relay-supplied admin/party can never widen
40
+ * what we will sign. Every legal Canton party-id form is accepted (dots,
41
+ * non-hex namespaces, spaces) because we identify parties by their protobuf
42
+ * type, not by a regex.
43
+ *
44
+ * As an independent backstop we also assert that NO party value anywhere in the
45
+ * choice argument is a *recipient/sender* other than {wallet.party, intended
46
+ * payTo} (the instrument admin is allowed ONLY at its `instrumentId.admin`
47
+ * position). Any relay-injected extra leg paying a third party shows up as a
48
+ * foreign party and is rejected.
49
+ *
50
+ * UNAMBIGUOUS DECODE (no first-vs-last-wins divergence). Every field we read is
51
+ * NON-REPEATED in the schema, so we reject ANY duplicate occurrence of it
52
+ * (`lenFieldUnique`) and reject any `Value` that sets a oneof member more than
53
+ * once or sets two different members (`assertSingleValueMember`), plus duplicate
54
+ * record-field labels. The protobuf wire format keeps the LAST occurrence of a
55
+ * non-repeated field/oneof member, and Canton's ScalaPB parser follows that; a
56
+ * hand-rolled FIRST-wins reader would otherwise let an attacker hide an inflated
57
+ * amount (or swapped receiver/choice) as a second occurrence past a decoy first
58
+ * one. We fail closed on the ambiguity instead of guessing.
59
+ *
60
+ * HASH BINDING (we must sign the hash OF the bytes we validated). Validating the
61
+ * bytes is necessary but not sufficient: a compromised relay can return honest
62
+ * bytes paired with the hash of a DIFFERENT transaction and swap the bytes it
63
+ * forwards to the participant. So `assertHashBinding` (wired in tx.ts) REQUIRES
64
+ * the relay-returned hash to equal a locally-recomputed hash of the validated
65
+ * bytes (or an explicit, off-by-default opt-in to trust the relay) and refuses
66
+ * otherwise — the Canton Ledger API itself mandates recomputing the hash when
67
+ * the preparing participant is untrusted. Any swap of receiver/amount/instrument
68
+ * changes the bytes, fails its exact-equality check here, AND changes the
69
+ * recomputed hash. Fail-closed in every case.
70
+ *
71
+ * FIELD MAP (com.daml.ledger.api.v2 Ledger API protos)
72
+ * ----------------------------------------------------
73
+ * PreparedTransaction .transaction = 1 (DamlTransaction)
74
+ * .metadata = 2 (Metadata)
75
+ * Metadata .submitter_info = 2 (SubmitterInfo)
76
+ * Metadata.SubmitterInfo .act_as (repeated) = 1 (string party)
77
+ * DamlTransaction .nodes (repeated) = 3 (Node)
78
+ * DamlTransaction.Node .v1 = 1000 (transaction.v1.Node)
79
+ * transaction.v1.Node .exercise = 3 (Exercise)
80
+ * transaction.v1.Exercise .template_id = 4 (Identifier)
81
+ * .choice_id = 9 (string)
82
+ * .chosen_value = 10 (Value)
83
+ * Value (oneof sum) .numeric = 6 (string)
84
+ * .party = 7 (string)
85
+ * .text = 8 (string)
86
+ * .optional = 10 (Optional)
87
+ * .list = 11 (List)
88
+ * .text_map = 12 (TextMap)
89
+ * .gen_map = 13 (GenMap)
90
+ * .record = 14 (Record)
91
+ * .variant = 15 (Variant)
92
+ * Record .fields (repeated) = 2 (RecordField)
93
+ * RecordField .label = 1 (string)
94
+ * .value = 2 (Value)
95
+ * List .elements (repeated) = 1 (Value)
96
+ * Optional .value = 1 (Value)
97
+ * Variant .value = 3 (Value)
98
+ * TextMap .entries (repeated) = 1 (TextMap.Entry)
99
+ * TextMap.Entry .key = 1 (string)
100
+ * .value = 2 (Value)
101
+ * GenMap .entries (repeated) = 1 (GenMap.Entry)
102
+ * GenMap.Entry .key = 1 (Value)
103
+ * .value = 2 (Value)
104
+ */
105
+ import { createHash, createPublicKey, timingSafeEqual } from "node:crypto";
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
+ pos += 8;
165
+ out.push({ field, wire });
166
+ }
167
+ else if (wire === WIRE_32) {
168
+ if (pos + 4 > buf.length)
169
+ throw new PreparedDecodeError("32-bit field overruns buffer");
170
+ pos += 4;
171
+ out.push({ field, wire });
172
+ }
173
+ else {
174
+ throw new PreparedDecodeError(`unsupported/obsolete wire type ${wire}`);
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+ /** All length-delimited submessages for a given field number, in order. */
180
+ function lenFields(fields, field) {
181
+ return fields
182
+ .filter((f) => f.field === field && f.wire === WIRE_LEN && f.bytes !== undefined)
183
+ .map((f) => f.bytes);
184
+ }
185
+ /**
186
+ * Strict accessor for a NON-REPEATED length-delimited field: returns its single
187
+ * submessage, `undefined` if absent, and THROWS if it occurs more than once.
188
+ *
189
+ * Why this exists (BYPASS: amount-inflation via duplicate oneof / field):
190
+ * the protobuf wire format mandates LAST-occurrence-wins for a non-repeated
191
+ * scalar or `oneof` member, and Canton's spec-conformant ScalaPB parser (which
192
+ * the participant runs on `execute` to recompute the V2 hash and interpret the
193
+ * transaction) follows that rule. A naive hand-rolled reader that takes the
194
+ * FIRST occurrence (our old `lenField`) would diverge: an attacker can place
195
+ * `Value.numeric` twice inside the SAME amount `Value` — a decoy "1.0" first,
196
+ * the real "9999.0" second — and the first-wins reader sees the decoy while the
197
+ * participant executes the inflated amount. There is no legitimate reason for a
198
+ * non-repeated field to appear twice, so we FAIL CLOSED on any duplicate rather
199
+ * than guess which occurrence the participant will honour. This makes our decode
200
+ * unambiguous and forces it to agree with the spec parser for everything we
201
+ * read (amount/party/text/record/label/value).
202
+ */
203
+ function lenFieldUnique(fields, field, what) {
204
+ const all = lenFields(fields, field);
205
+ if (all.length > 1) {
206
+ throw new PreparedDecodeError(`non-repeated field ${field} (${what}) appears ${all.length} times — ` +
207
+ `ambiguous encoding (last-occurrence-wins on the participant), refusing to sign`);
208
+ }
209
+ return all[0];
210
+ }
211
+ /**
212
+ * Number of length-delimited occurrences of a field (for duplicate detection on
213
+ * scalar `Value` oneof members, which must each appear at most once).
214
+ */
215
+ function countLenField(fields, field) {
216
+ return lenFields(fields, field).length;
217
+ }
218
+ /** Decode a length-delimited field as UTF-8 (strict). */
219
+ function utf8(bytes) {
220
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
221
+ }
222
+ /** All WIRE_VARINT occurrences of a field number, in order. */
223
+ function varintFields(fields, field) {
224
+ return fields.filter((f) => f.field === field && f.wire === WIRE_VARINT && f.varintBytes !== undefined);
225
+ }
226
+ /** Number of WIRE_VARINT occurrences of a field (duplicate-oneof detection). */
227
+ function countVarintField(fields, field) {
228
+ return varintFields(fields, field).length;
229
+ }
230
+ /**
231
+ * Re-decode a raw varint byte-sequence as a BigInt — full int64 precision, no
232
+ * `2**shift` float loss. proto3 int64 is up to 10 bytes (64 data bits). We cap
233
+ * the data shift at 63 bits and fail closed on anything longer/malformed rather
234
+ * than silently truncating. (`readVarint` already validated framing during
235
+ * decode; this re-reads the SAME bytes precisely.)
236
+ */
237
+ function varintToBigInt(bytes) {
238
+ let value = 0n;
239
+ let shift = 0n;
240
+ for (let i = 0; i < bytes.length; i++) {
241
+ const b = bytes[i];
242
+ value += BigInt(b & 0x7f) << shift;
243
+ if ((b & 0x80) === 0)
244
+ return value;
245
+ shift += 7n;
246
+ if (shift > 63n)
247
+ break;
248
+ }
249
+ throw new PreparedDecodeError("truncated or over-long varint");
250
+ }
251
+ /**
252
+ * Strict accessor for a NON-REPEATED varint field (Daml Int / Time): returns its
253
+ * single value as a BigInt, `undefined` if absent, and THROWS if it occurs more
254
+ * than once. Same fail-closed rationale as `lenFieldUnique`: the wire format is
255
+ * last-occurrence-wins for a non-repeated scalar, so a duplicate would let a
256
+ * decoy-first/real-second split diverge our read from the participant's. There
257
+ * is no legitimate reason for nonce/expiresAt to appear twice.
258
+ */
259
+ function varintFieldUnique(fields, field, what) {
260
+ const all = varintFields(fields, field);
261
+ if (all.length > 1) {
262
+ throw new PreparedDecodeError(`non-repeated varint field ${field} (${what}) appears ${all.length} times — ` +
263
+ `ambiguous encoding (last-occurrence-wins on the participant), refusing to sign`);
264
+ }
265
+ const only = all[0];
266
+ if (only === undefined)
267
+ return undefined;
268
+ return varintToBigInt(only.varintBytes);
269
+ }
270
+ /* ────────────────────────────────────────────────────────────────────────
271
+ * Daml `Value` (the choice-argument tree). We only need the leaves that carry
272
+ * security-relevant data: party (recipients), numeric (amount), text (e.g.
273
+ * instrument id). We descend record / list / optional / variant containers.
274
+ * ──────────────────────────────────────────────────────────────────────── */
275
+ // Value oneof tags (com.daml.ledger.api.v2.Value.Sum).
276
+ // unit=1 bool=2 int64=3 date=4 timestamp=5 numeric=6 party=7 text=8
277
+ // contract_id=9 optional=10 list=11 text_map=12 gen_map=13 record=14
278
+ // variant=15 enum=16
279
+ const V_UNIT = 1; // Value.unit (Daml `()`; google.protobuf.Empty). Non-party leaf.
280
+ const V_BOOL = 2; // Value.bool. Varint. Non-party leaf.
281
+ const V_INT64 = 3; // Value.int64 (Daml `Int`, e.g. TransferCommand.nonce). Varint.
282
+ const V_DATE = 4; // Value.date (Daml `Date`; days since epoch). Varint. Non-party leaf.
283
+ const V_TIMESTAMP = 5; // Value.timestamp (Daml `Time`, e.g. expiresAt). Varint µs.
284
+ const V_NUMERIC = 6;
285
+ const V_PARTY = 7;
286
+ const V_TEXT = 8;
287
+ const V_CONTRACT_ID = 9; // Value.contract_id (e.g. transfer.inputHoldingCids). Non-party leaf.
288
+ const V_OPTIONAL = 10;
289
+ const V_LIST = 11;
290
+ const V_TEXT_MAP = 12; // Value.text_map (TextMap; string keys, Value values)
291
+ const V_GEN_MAP = 13; // Value.gen_map (GenMap; Value keys AND Value values)
292
+ const V_RECORD = 14;
293
+ const V_VARIANT = 15;
294
+ const V_ENUM = 16; // Value.enum (Identifier + constructor; carries no Party). Non-party leaf.
295
+ /**
296
+ * The COMPLETE com.daml.ledger.api.v2.interactive `Value` oneof member set. We
297
+ * fail closed (refuse to sign) on ANY `Value` that sets a member outside this
298
+ * set: the leaf reader + the foreign-party backstop only recognize these, so an
299
+ * unknown/future member's subtree would be silently dropped — and could hide a
300
+ * foreign recipient party. Mirrors the codebase's fail-closed stance on unknown
301
+ * node versions / node types.
302
+ */
303
+ const COMPLETE_VALUE_MEMBERS = new Set([
304
+ V_UNIT,
305
+ V_BOOL,
306
+ V_INT64,
307
+ V_DATE,
308
+ V_TIMESTAMP,
309
+ V_NUMERIC,
310
+ V_PARTY,
311
+ V_TEXT,
312
+ V_CONTRACT_ID,
313
+ V_OPTIONAL,
314
+ V_LIST,
315
+ V_TEXT_MAP,
316
+ V_GEN_MAP,
317
+ V_RECORD,
318
+ V_VARIANT,
319
+ V_ENUM,
320
+ ]);
321
+ // Record / RecordField / List / Optional / Variant inner field numbers.
322
+ const RECORD_FIELDS = 2;
323
+ const RF_LABEL = 1;
324
+ const RF_VALUE = 2;
325
+ const LIST_ELEMENTS = 1;
326
+ const OPTIONAL_VALUE = 1;
327
+ const VARIANT_VALUE = 3;
328
+ // GenMap / TextMap inner field numbers. Both wrap a repeated `Entry` at field 1;
329
+ // GenMap.Entry has Value key=1 + Value value=2, TextMap.Entry has string key=1 +
330
+ // Value value=2 (so only TextMap *values* can carry a party leaf).
331
+ const MAP_ENTRIES = 1;
332
+ const ENTRY_KEY = 1;
333
+ const ENTRY_VALUE = 2;
334
+ const MAX_DEPTH = 64;
335
+ /** Decode a `Value`'s record fields (in order). Throws if it is not a record. */
336
+ function recordEntries(value) {
337
+ assertSingleValueMember(value);
338
+ const v = decodeMessage(value);
339
+ const rec = lenFieldUnique(v, V_RECORD, "Value.record");
340
+ if (rec === undefined)
341
+ throw new PreparedDecodeError("expected a Value.record");
342
+ const recFields = decodeMessage(rec);
343
+ const entries = lenFields(recFields, RECORD_FIELDS).map((rf) => {
344
+ const f = decodeMessage(rf);
345
+ // A RecordField's label and value are each non-repeated; reject duplicates
346
+ // so a last-wins parser cannot disagree with us about a field's value.
347
+ const labelBytes = lenFieldUnique(f, RF_LABEL, "RecordField.label");
348
+ const valBytes = lenFieldUnique(f, RF_VALUE, "RecordField.value");
349
+ if (valBytes === undefined)
350
+ throw new PreparedDecodeError("record field missing value");
351
+ return { label: labelBytes ? utf8(labelBytes) : "", value: valBytes };
352
+ });
353
+ // Reject duplicate NON-EMPTY labels: by-label lookup would pick the first
354
+ // while a last-wins consumer keying by label would pick the last (decoy/real
355
+ // split). Empty labels (normalized encodings) are positional, so allowed.
356
+ const seen = new Set();
357
+ for (const e of entries) {
358
+ if (e.label === "")
359
+ continue;
360
+ if (seen.has(e.label)) {
361
+ throw new PreparedDecodeError(`record contains duplicate field label ${JSON.stringify(e.label)} — ` +
362
+ `ambiguous encoding, refusing to sign`);
363
+ }
364
+ seen.add(e.label);
365
+ }
366
+ return entries;
367
+ }
368
+ /**
369
+ * A Daml `Value` is a `oneof sum` — at most one member may be set. A
370
+ * spec-conformant parser keeps the LAST member it sees within a oneof, so if an
371
+ * attacker sets two members (or the same member twice) our by-type reads could
372
+ * pick a different one than the participant. Enforce that the scalar/structural
373
+ * members we ever read each occur AT MOST ONCE and that no two DIFFERENT members
374
+ * are present simultaneously. Fail closed on any ambiguity.
375
+ */
376
+ function assertSingleValueMember(value) {
377
+ const v = decodeMessage(value);
378
+ // Length-delimited oneof members.
379
+ const lenMembers = [
380
+ { tag: V_NUMERIC, what: "Value.numeric" },
381
+ { tag: V_PARTY, what: "Value.party" },
382
+ { tag: V_TEXT, what: "Value.text" },
383
+ { tag: V_RECORD, what: "Value.record" },
384
+ { tag: V_OPTIONAL, what: "Value.optional" },
385
+ { tag: V_LIST, what: "Value.list" },
386
+ { tag: V_TEXT_MAP, what: "Value.text_map" },
387
+ { tag: V_GEN_MAP, what: "Value.gen_map" },
388
+ { tag: V_VARIANT, what: "Value.variant" },
389
+ ];
390
+ // Varint oneof members (Daml Int / Time). Counted separately because they use
391
+ // wire type 0, not length-delimited; a duplicate int64 (decoy nonce first,
392
+ // real nonce second) must be caught with the SAME fail-closed rigor.
393
+ const varintMembers = [
394
+ { tag: V_INT64, what: "Value.int64" },
395
+ { tag: V_TIMESTAMP, what: "Value.timestamp" },
396
+ ];
397
+ let present = 0;
398
+ for (const m of lenMembers) {
399
+ const count = countLenField(v, m.tag);
400
+ if (count > 1) {
401
+ throw new PreparedDecodeError(`Value oneof member ${m.what} set ${count} times — ambiguous encoding ` +
402
+ `(last-occurrence-wins on the participant), refusing to sign`);
403
+ }
404
+ if (count === 1)
405
+ present++;
406
+ }
407
+ for (const m of varintMembers) {
408
+ const count = countVarintField(v, m.tag);
409
+ if (count > 1) {
410
+ throw new PreparedDecodeError(`Value oneof member ${m.what} set ${count} times — ambiguous encoding ` +
411
+ `(last-occurrence-wins on the participant), refusing to sign`);
412
+ }
413
+ if (count === 1)
414
+ present++;
415
+ }
416
+ if (present > 1) {
417
+ throw new PreparedDecodeError("Value sets more than one oneof member — ambiguous encoding, refusing to sign");
418
+ }
419
+ }
420
+ /**
421
+ * Read a leaf scalar (party/numeric/text) out of a `Value`, if it is one.
422
+ *
423
+ * Hardened against the duplicate-oneof amount-inflation bypass: we first assert
424
+ * the `Value` sets at most one oneof member, each at most once
425
+ * (`assertSingleValueMember`), then read that single member with the strict
426
+ * unique accessor. A `Value` carrying `Value.numeric` twice (decoy "1.0" then
427
+ * real "9999.0") — which a last-wins ScalaPB parser would read as the inflated
428
+ * amount — is rejected here BEFORE any amount comparison, instead of silently
429
+ * taking the first (decoy) occurrence. Fail closed.
430
+ */
431
+ function leafOf(value) {
432
+ assertSingleValueMember(value);
433
+ const v = decodeMessage(value);
434
+ const party = lenFieldUnique(v, V_PARTY, "Value.party");
435
+ if (party !== undefined)
436
+ return { kind: "party", value: utf8(party) };
437
+ const numeric = lenFieldUnique(v, V_NUMERIC, "Value.numeric");
438
+ if (numeric !== undefined)
439
+ return { kind: "numeric", value: utf8(numeric) };
440
+ const text = lenFieldUnique(v, V_TEXT, "Value.text");
441
+ if (text !== undefined)
442
+ return { kind: "text", value: utf8(text) };
443
+ // Daml Int / Time leaves (e.g. TransferCommand.nonce / expiresAt). Read as
444
+ // BigInt-precise decimal strings via the unique varint accessor.
445
+ const int64 = varintFieldUnique(v, V_INT64, "Value.int64");
446
+ // proto3 stores a negative int64 as the 64-bit two's-complement varint
447
+ // (10 bytes, top data bit set). Sign-interpret so a negative nonce decodes as
448
+ // a negative value (the sanity check rejects it) rather than ~1.8e19, AND so
449
+ // exact-equality nonce comparison agrees with the participant's signed read.
450
+ if (int64 !== undefined)
451
+ return { kind: "int64", value: asSignedInt64(int64).toString() };
452
+ // Timestamp µs are always non-negative in practice; keep the raw (unsigned)
453
+ // value — Canton Time is post-epoch, so a "negative" timestamp would be a
454
+ // pre-1970 microsecond count we'd reject on the past-expiry check anyway.
455
+ const timestamp = varintFieldUnique(v, V_TIMESTAMP, "Value.timestamp");
456
+ if (timestamp !== undefined)
457
+ return { kind: "timestamp", value: timestamp.toString() };
458
+ return undefined;
459
+ }
460
+ /** Interpret an unsigned 64-bit varint value as a signed two's-complement
461
+ * int64. Values >= 2^63 map to their negative counterpart. */
462
+ function asSignedInt64(u) {
463
+ return u >= 1n << 63n ? u - (1n << 64n) : u;
464
+ }
465
+ /**
466
+ * Fail closed on a `Value` that sets any oneof member outside the COMPLETE known
467
+ * set. The leaf reader + the backstop's container descent recognize only the
468
+ * standard members, so an unknown/future member's subtree would be silently
469
+ * dropped — and could hide a foreign recipient party. Refuse rather than guess.
470
+ */
471
+ function assertKnownValueMembers(value) {
472
+ for (const f of decodeMessage(value)) {
473
+ if (!COMPLETE_VALUE_MEMBERS.has(f.field)) {
474
+ throw new PreparedDecodeError(`Daml Value sets an unknown oneof member (field ${f.field}) — refusing to sign ` +
475
+ `(cannot prove the member carries no foreign recipient party)`);
476
+ }
477
+ }
478
+ }
479
+ /**
480
+ * Recursively collect every party-typed leaf in a `Value` tree, EXCEPT those
481
+ * sitting inside an `instrumentId`-shaped sub-record (admin party at a
482
+ * non-recipient position). Used for the foreign-recipient backstop. We descend
483
+ * every Value container that can hold a party leaf: records, lists, optionals,
484
+ * variants, and BOTH map shapes — GenMap (party can hide in a Value key OR a
485
+ * Value value) and TextMap (string keys cannot be parties, so only values are
486
+ * descended). Skipping the map oneof members would let a relay smuggle an extra
487
+ * recipient inside a GenMap and slip past the backstop (defense-in-depth: the
488
+ * receiver itself must still be a plain Party at the transfer.receiver position,
489
+ * which extractTransfer pins, so this is not a known redirect exploit). The
490
+ * caller excludes the instrument admin from the recipient set without trusting
491
+ * its value. Fails closed on any unknown Value oneof member (assertKnownValueMembers).
492
+ */
493
+ function collectPartyLeaves(value, out, depth) {
494
+ if (depth > MAX_DEPTH)
495
+ throw new PreparedDecodeError("Value nesting too deep");
496
+ // Fail closed on any Value oneof member outside the complete known set BEFORE
497
+ // we walk it: the leaf reader + container descent below recognize only the
498
+ // standard members, so an unknown/future member's subtree would otherwise be
499
+ // silently dropped — and could hide a foreign recipient party from this
500
+ // backstop. (Known non-party scalars — unit/bool/date/contract_id/enum — are
501
+ // in the set: leafOf returns undefined for them and no container matches, so
502
+ // they are correctly ignored, not rejected.)
503
+ assertKnownValueMembers(value);
504
+ const leaf = leafOf(value);
505
+ if (leaf) {
506
+ if (leaf.kind === "party")
507
+ out.push(leaf.value);
508
+ return; // scalar leaf — nothing to recurse into
509
+ }
510
+ // Not a scalar leaf — it must be a single structural oneof member. Reject any
511
+ // ambiguous (duplicate/multi-member) Value before descending so a smuggled
512
+ // extra party cannot hide behind a last-wins parser disagreement.
513
+ assertSingleValueMember(value);
514
+ const v = decodeMessage(value);
515
+ // record → recurse each field value
516
+ const rec = lenFieldUnique(v, V_RECORD, "Value.record");
517
+ if (rec !== undefined) {
518
+ const recFields = decodeMessage(rec);
519
+ for (const rf of lenFields(recFields, RECORD_FIELDS)) {
520
+ const f = decodeMessage(rf);
521
+ const valBytes = lenFieldUnique(f, RF_VALUE, "RecordField.value");
522
+ if (valBytes !== undefined)
523
+ collectPartyLeaves(valBytes, out, depth + 1);
524
+ }
525
+ return;
526
+ }
527
+ // list → recurse elements
528
+ const list = lenFieldUnique(v, V_LIST, "Value.list");
529
+ if (list !== undefined) {
530
+ for (const el of lenFields(decodeMessage(list), LIST_ELEMENTS)) {
531
+ collectPartyLeaves(el, out, depth + 1);
532
+ }
533
+ return;
534
+ }
535
+ // optional → recurse inner value
536
+ const opt = lenFieldUnique(v, V_OPTIONAL, "Value.optional");
537
+ if (opt !== undefined) {
538
+ const inner = lenFieldUnique(decodeMessage(opt), OPTIONAL_VALUE, "Optional.value");
539
+ if (inner !== undefined)
540
+ collectPartyLeaves(inner, out, depth + 1);
541
+ return;
542
+ }
543
+ // variant → recurse inner value
544
+ const variant = lenFieldUnique(v, V_VARIANT, "Value.variant");
545
+ if (variant !== undefined) {
546
+ const inner = lenFieldUnique(decodeMessage(variant), VARIANT_VALUE, "Variant.value");
547
+ if (inner !== undefined)
548
+ collectPartyLeaves(inner, out, depth + 1);
549
+ return;
550
+ }
551
+ // gen_map → recurse BOTH the key and the value of every entry. In a GenMap
552
+ // both Entry.key and Entry.value are full `Value`s, so a party can hide in
553
+ // either; descend both so a smuggled recipient cannot escape the backstop.
554
+ const genMap = lenFieldUnique(v, V_GEN_MAP, "Value.gen_map");
555
+ if (genMap !== undefined) {
556
+ for (const entry of lenFields(decodeMessage(genMap), MAP_ENTRIES)) {
557
+ const e = decodeMessage(entry);
558
+ const key = lenFieldUnique(e, ENTRY_KEY, "GenMap.Entry.key");
559
+ if (key !== undefined)
560
+ collectPartyLeaves(key, out, depth + 1);
561
+ const val = lenFieldUnique(e, ENTRY_VALUE, "GenMap.Entry.value");
562
+ if (val !== undefined)
563
+ collectPartyLeaves(val, out, depth + 1);
564
+ }
565
+ return;
566
+ }
567
+ // text_map → recurse entry VALUES only (TextMap keys are plain strings, never
568
+ // parties), so a party leaf can only appear in a value.
569
+ const textMap = lenFieldUnique(v, V_TEXT_MAP, "Value.text_map");
570
+ if (textMap !== undefined) {
571
+ for (const entry of lenFields(decodeMessage(textMap), MAP_ENTRIES)) {
572
+ const val = lenFieldUnique(decodeMessage(entry), ENTRY_VALUE, "TextMap.Entry.value");
573
+ if (val !== undefined)
574
+ collectPartyLeaves(val, out, depth + 1);
575
+ }
576
+ }
577
+ }
578
+ /**
579
+ * Collect every party-typed leaf in a `GlobalKeyWithMaintainers` (the contract-
580
+ * key metadata attached to Create.key (field 8) / Exercise.key (field 15) /
581
+ * Fetch.key (field 9) / QueryByKey.key (field 5)). A contract key carries
582
+ * parties in TWO positions, BOTH covered by the agent's signature once a V3
583
+ * hashing scheme is negotiated (the V3 NodeHashBuilder hashes keyOpt +
584
+ * maintainers): the `maintainers` (GKWM field 2, repeated string party) AND the
585
+ * inner contract-key `Value` (GKWM.key=1 → GlobalKey.key=3), which is a full
586
+ * Daml `Value` tree that can itself hold a party leaf. Both were previously
587
+ * invisible to the foreign-party backstop, so a relay could place ATTACKER ONLY
588
+ * in a key position and slip past `assertNoForeignParties` (the file's stated
589
+ * invariant is that a party introduced ANYWHERE the signature covers is visible
590
+ * to the backstop). We scan both and feed them into the backstop's `elsewhere`
591
+ * set — a key party is NEVER a legitimate recipient position, so it must equal
592
+ * an already-allowed party or be rejected. Fail-closed; honest CC
593
+ * transfers/commands carry no contract key, so this never affects the happy path.
594
+ */
595
+ function collectGlobalKeyWithMaintainersParties(gkwm, out, depth) {
596
+ if (depth > MAX_DEPTH)
597
+ throw new PreparedDecodeError("contract key nesting too deep");
598
+ const f = decodeMessage(gkwm);
599
+ // maintainers (repeated string party) — authorization parties on the key.
600
+ out.push(...stringFields(f, GKWM_MAINTAINERS));
601
+ // the inner GlobalKey.key Value (the contract-key payload) — descend it as a
602
+ // full Daml Value so a party leaf hidden inside the key surfaces too.
603
+ const globalKey = lenFieldUnique(f, GKWM_KEY, "GlobalKeyWithMaintainers.key");
604
+ if (globalKey !== undefined) {
605
+ const keyValue = lenFieldUnique(decodeMessage(globalKey), GLOBALKEY_KEY, "GlobalKey.key");
606
+ if (keyValue !== undefined)
607
+ collectPartyLeaves(keyValue, out, depth + 1);
608
+ }
609
+ }
610
+ /* ────────────────────────────────────────────────────────────────────────
611
+ * PreparedTransaction structural descent.
612
+ * ──────────────────────────────────────────────────────────────────────── */
613
+ // PreparedTransaction / Metadata / DamlTransaction / Node field numbers.
614
+ // Schema-pinned against com.daml.ledger.api.v2.interactive (Canton 3.x):
615
+ // PreparedTransaction.transaction=1, .metadata=2
616
+ // Metadata.submitter_info=2 ; SubmitterInfo.act_as=1
617
+ // DamlTransaction.version=1, .roots=2, .nodes=3
618
+ // DamlTransaction.Node.node_id=1, oneof versioned_node { v1=1000 }
619
+ // transaction.v1.Node oneof node_type { create=1 fetch=2 exercise=3
620
+ // rollback=4 query_by_key=5 }
621
+ // Exercise.choice_id=9, .chosen_value=10, .children=12, .exercise_result=13
622
+ // Create.argument=5 ; Rollback.children=1
623
+ const PT_TRANSACTION = 1;
624
+ const PT_METADATA = 2;
625
+ const MD_SUBMITTER_INFO = 2;
626
+ const SI_ACT_AS = 1;
627
+ // Metadata "needs to be signed" block (interactive_submission_service.proto):
628
+ // submitter_info=2 synchronizer_id=3 mediator_group=4 transaction_uuid=5
629
+ // preparation_time=6 input_contracts=7 min_ledger_effective_time=9
630
+ // max_ledger_effective_time=10 max_record_time=11
631
+ // (field 8 = global_key_mapping is the ONLY field NOT signed.) Every signed
632
+ // field is covered by the agent's signature, so verify must constrain the ones
633
+ // that affect WHERE/WHEN the transfer lands or WHICH parties it touches.
634
+ const MD_SYNCHRONIZER_ID = 3;
635
+ const MD_PREPARATION_TIME = 6;
636
+ const MD_INPUT_CONTRACTS = 7;
637
+ const MD_MIN_LEDGER_EFFECTIVE_TIME = 9;
638
+ const MD_MAX_LEDGER_EFFECTIVE_TIME = 10;
639
+ const MD_MAX_RECORD_TIME = 11;
640
+ // Metadata.InputContract: oneof contract { v1 Create = 1 }, created_at=1000,
641
+ // event_blob=1002. We descend the Create v1 argument for the party backstop.
642
+ const IC_V1 = 1;
643
+ const DT_ROOTS = 2;
644
+ const DT_NODES = 3;
645
+ const NODE_ID = 1;
646
+ const NODE_V1 = 1000;
647
+ // transaction.v1.Node.node_type oneof members.
648
+ const V1NODE_CREATE = 1;
649
+ const V1NODE_FETCH = 2;
650
+ const V1NODE_EXERCISE = 3;
651
+ const V1NODE_ROLLBACK = 4;
652
+ const V1NODE_QUERY_BY_KEY = 5;
653
+ // interactive.transaction.v1.Exercise field numbers (interactive_submission_data.proto).
654
+ const EX_CONTRACT_ID = 2; // Exercise.contract_id — the exercised target contract.
655
+ const EX_TEMPLATE_ID = 4;
656
+ const EX_SIGNATORIES = 5;
657
+ const EX_STAKEHOLDERS = 6;
658
+ const EX_ACTING_PARTIES = 7;
659
+ const EX_CHOICE_ID = 9;
660
+ const EX_CHOSEN_VALUE = 10;
661
+ const EX_CHILDREN = 12;
662
+ const EX_RESULT = 13;
663
+ const EX_CHOICE_OBSERVERS = 14;
664
+ const EX_KEY = 15; // Exercise.key : optional GlobalKeyWithMaintainers
665
+ // interactive.transaction.v1.Create field numbers.
666
+ const CREATE_ARGUMENT = 5;
667
+ const CREATE_SIGNATORIES = 6;
668
+ const CREATE_STAKEHOLDERS = 7;
669
+ const CREATE_KEY = 8; // Create.key : optional GlobalKeyWithMaintainers
670
+ // interactive.transaction.v1.Fetch field numbers (party lists; no Daml Value arg).
671
+ const FETCH_SIGNATORIES = 5;
672
+ const FETCH_STAKEHOLDERS = 6;
673
+ const FETCH_ACTING_PARTIES = 7;
674
+ const FETCH_KEY = 9; // Fetch.key : optional GlobalKeyWithMaintainers
675
+ // interactive.transaction.v1.QueryByKey + GlobalKeyWithMaintainers + GlobalKey.
676
+ const QBK_KEY = 5; // QueryByKey.key : GlobalKeyWithMaintainers
677
+ const GKWM_KEY = 1; // GlobalKeyWithMaintainers.key : GlobalKey
678
+ const GKWM_MAINTAINERS = 2; // GlobalKeyWithMaintainers.maintainers (repeated string party)
679
+ const GLOBALKEY_KEY = 3; // GlobalKey.key : Value (the contract-key Value tree)
680
+ const ROLLBACK_CHILDREN = 1;
681
+ // Identifier (com.daml.ledger.api.v2.value.Identifier): module_name=2, entity_name=3.
682
+ // (package_id=1 is intentionally NOT pinned — it varies with package upgrades.)
683
+ const ID_PACKAGE_ID = 1;
684
+ const ID_MODULE_NAME = 2;
685
+ const ID_ENTITY_NAME = 3;
686
+ /** The transfer choice on a TransferFactory. */
687
+ const TRANSFER_CHOICE = "TransferFactory_Transfer";
688
+ /** All values of a repeated `string` field, decoded UTF-8 (in order). */
689
+ function stringFields(fields, field) {
690
+ return lenFields(fields, field).map(utf8);
691
+ }
692
+ /**
693
+ * Decode an `Identifier` (value.proto) to its `module:entity` qualified name.
694
+ * The package id (field 1) is deliberately DROPPED: it changes across package
695
+ * upgrades, so pinning it would break honest transfers; module + entity together
696
+ * unambiguously name the template. Returns undefined if the identifier is empty
697
+ * or does not carry both names.
698
+ */
699
+ function identifierQualifiedName(idBytes) {
700
+ // An Identifier may be serialized either as the structured message
701
+ // {package_id=1, module_name=2, entity_name=3} OR (in some Canton encodings of
702
+ // the interactive node template_id) as a single flat string "pkg:module:entity"
703
+ // / "module:entity". Handle both: try the structured fields first; only if a
704
+ // structured decode succeeds AND carries a module/entity field do we use it. A
705
+ // flat string is NOT valid protobuf, so `decodeMessage` may throw — fall back
706
+ // to the flat-string interpretation in that case.
707
+ let moduleName;
708
+ let entityName;
709
+ let structuredHadOtherFields = false;
710
+ try {
711
+ const f = decodeMessage(idBytes);
712
+ moduleName = lenFieldUnique(f, ID_MODULE_NAME, "Identifier.module_name");
713
+ entityName = lenFieldUnique(f, ID_ENTITY_NAME, "Identifier.entity_name");
714
+ structuredHadOtherFields = f.some((x) => x.field === ID_PACKAGE_ID);
715
+ }
716
+ catch {
717
+ /* not structured protobuf — treat as a flat string below */
718
+ }
719
+ if (moduleName !== undefined && entityName !== undefined) {
720
+ return `${utf8(moduleName)}:${utf8(entityName)}`;
721
+ }
722
+ // A structured Identifier with a package_id but no module/entity is malformed
723
+ // for our purpose; only fall through to flat-string when it did NOT look like a
724
+ // structured Identifier at all.
725
+ if (structuredHadOtherFields)
726
+ return undefined;
727
+ // Flat-string form. A template id string is "package:Module.Path:Entity" or
728
+ // "Module.Path:Entity"; normalize to the trailing two colon-separated segments
729
+ // (module:entity), dropping a leading package-id when present.
730
+ let flat;
731
+ try {
732
+ flat = utf8(idBytes);
733
+ }
734
+ catch {
735
+ return undefined;
736
+ }
737
+ if (flat.length === 0 || flat.includes(""))
738
+ return undefined;
739
+ const parts = flat.split(":");
740
+ if (parts.length >= 3)
741
+ return `${parts[parts.length - 2]}:${parts[parts.length - 1]}`;
742
+ if (parts.length === 2)
743
+ return flat;
744
+ return undefined;
745
+ }
746
+ /**
747
+ * Decode the inner `transaction.v1.Node` (the node_type oneof). Returns the
748
+ * single set member and the per-kind payload we validate. A node that sets NO
749
+ * known member, or MORE THAN ONE, is reported as `kind: undefined` so the
750
+ * invariant layer rejects it (fail closed — an ambiguous/opaque node could be
751
+ * executed by the participant as something other than what we read).
752
+ *
753
+ * Beyond the Daml `Value` payloads we also collect every node-level
754
+ * `repeated string party` field (signatories/stakeholders/acting_parties/
755
+ * choice_observers and a QueryByKey's key maintainers) into `partyMeta`, so the
756
+ * foreign-party backstop sees a party placed in authorization/visibility
757
+ * metadata (a position outside the Value-leaf walk), and the exercised
758
+ * template's `module:entity` so the arm can pin WHICH template the choice runs
759
+ * against.
760
+ */
761
+ function decodeV1Node(v1Body) {
762
+ const v1n = decodeMessage(v1Body);
763
+ // Detect which (if any) node_type oneof members are present. Each is
764
+ // non-repeated; a duplicate is ambiguous under last-wins so reject via the
765
+ // unique accessor. Count DISTINCT members set — more than one ⇒ reject.
766
+ const create = lenFieldUnique(v1n, V1NODE_CREATE, "v1.Node.create");
767
+ const fetch = lenFieldUnique(v1n, V1NODE_FETCH, "v1.Node.fetch");
768
+ const exercise = lenFieldUnique(v1n, V1NODE_EXERCISE, "v1.Node.exercise");
769
+ const rollback = lenFieldUnique(v1n, V1NODE_ROLLBACK, "v1.Node.rollback");
770
+ const queryByKey = lenFieldUnique(v1n, V1NODE_QUERY_BY_KEY, "v1.Node.query_by_key");
771
+ const present = [create, fetch, exercise, rollback, queryByKey].filter((m) => m !== undefined);
772
+ if (present.length === 0)
773
+ return { children: [], values: [], partyMeta: [] }; // kind undefined ⇒ reject
774
+ if (present.length > 1)
775
+ return { children: [], values: [], partyMeta: [] }; // ambiguous oneof ⇒ reject
776
+ if (exercise !== undefined) {
777
+ const exFields = decodeMessage(exercise);
778
+ const choiceIdBytes = lenFieldUnique(exFields, EX_CHOICE_ID, "Exercise.choice_id");
779
+ const chosen = lenFieldUnique(exFields, EX_CHOSEN_VALUE, "Exercise.chosen_value");
780
+ const result = lenFieldUnique(exFields, EX_RESULT, "Exercise.exercise_result");
781
+ const children = lenFields(exFields, EX_CHILDREN).map(utf8);
782
+ const tmpl = lenFieldUnique(exFields, EX_TEMPLATE_ID, "Exercise.template_id");
783
+ const cidBytes = lenFieldUnique(exFields, EX_CONTRACT_ID, "Exercise.contract_id");
784
+ const values = [];
785
+ if (chosen !== undefined)
786
+ values.push(chosen);
787
+ if (result !== undefined)
788
+ values.push(result);
789
+ const partyMeta = [
790
+ ...stringFields(exFields, EX_SIGNATORIES),
791
+ ...stringFields(exFields, EX_STAKEHOLDERS),
792
+ ...stringFields(exFields, EX_ACTING_PARTIES),
793
+ ...stringFields(exFields, EX_CHOICE_OBSERVERS),
794
+ ];
795
+ // Exercise.key (field 15, optional GlobalKeyWithMaintainers): a contract-key
796
+ // party (maintainer or inner key Value leaf) is a position the agent's
797
+ // signature covers under V3 hashing — scan it so a relay cannot hide a
798
+ // foreign party there (closes the contract-key backstop blind spot).
799
+ const exKey = lenFieldUnique(exFields, EX_KEY, "Exercise.key");
800
+ if (exKey !== undefined)
801
+ collectGlobalKeyWithMaintainersParties(exKey, partyMeta, 0);
802
+ const dec = choiceIdBytes !== undefined && chosen !== undefined
803
+ ? {
804
+ choiceId: utf8(choiceIdBytes),
805
+ chosenValue: chosen,
806
+ contractId: cidBytes !== undefined ? utf8(cidBytes) : undefined,
807
+ templateQualifiedName: tmpl !== undefined ? identifierQualifiedName(tmpl) : undefined,
808
+ }
809
+ : undefined;
810
+ return { kind: "exercise", exercise: dec, children, values, partyMeta };
811
+ }
812
+ if (create !== undefined) {
813
+ const cFields = decodeMessage(create);
814
+ const arg = lenFieldUnique(cFields, CREATE_ARGUMENT, "Create.argument");
815
+ const partyMeta = [
816
+ ...stringFields(cFields, CREATE_SIGNATORIES),
817
+ ...stringFields(cFields, CREATE_STAKEHOLDERS),
818
+ ];
819
+ // Create.key (field 8, optional GlobalKeyWithMaintainers): scan the key
820
+ // maintainers + inner contract-key Value so a foreign party placed ONLY in a
821
+ // consequence Create's key (a position the V3 hash signs) surfaces.
822
+ const cKey = lenFieldUnique(cFields, CREATE_KEY, "Create.key");
823
+ if (cKey !== undefined)
824
+ collectGlobalKeyWithMaintainersParties(cKey, partyMeta, 0);
825
+ return { kind: "create", children: [], values: arg !== undefined ? [arg] : [], partyMeta };
826
+ }
827
+ if (rollback !== undefined) {
828
+ const rFields = decodeMessage(rollback);
829
+ const children = lenFields(rFields, ROLLBACK_CHILDREN).map(utf8);
830
+ return { kind: "rollback", children, values: [], partyMeta: [] };
831
+ }
832
+ if (fetch !== undefined) {
833
+ // Fetch carries no Daml `Value` argument, but DOES carry node-level party
834
+ // lists (signatories/stakeholders/acting_parties) — collect them so the
835
+ // backstop sees a foreign party smuggled onto a fetch consequence.
836
+ const fFields = decodeMessage(fetch);
837
+ const partyMeta = [
838
+ ...stringFields(fFields, FETCH_SIGNATORIES),
839
+ ...stringFields(fFields, FETCH_STAKEHOLDERS),
840
+ ...stringFields(fFields, FETCH_ACTING_PARTIES),
841
+ ];
842
+ // Fetch.key (field 9, optional GlobalKeyWithMaintainers): scan its parties
843
+ // (maintainers + inner key Value) too.
844
+ const fKey = lenFieldUnique(fFields, FETCH_KEY, "Fetch.key");
845
+ if (fKey !== undefined)
846
+ collectGlobalKeyWithMaintainersParties(fKey, partyMeta, 0);
847
+ return { kind: "fetch", children: [], values: [], partyMeta };
848
+ }
849
+ // query_by_key — carries no Value argument, but its GlobalKeyWithMaintainers
850
+ // carries key maintainer parties AND an inner contract-key Value that can hold
851
+ // a party leaf; collect BOTH for the backstop (previously only maintainers
852
+ // were scanned, so a party hidden in the inner key Value escaped).
853
+ const qFields = decodeMessage(queryByKey);
854
+ const partyMeta = [];
855
+ const key = lenFieldUnique(qFields, QBK_KEY, "QueryByKey.key");
856
+ if (key !== undefined)
857
+ collectGlobalKeyWithMaintainersParties(key, partyMeta, 0);
858
+ return { kind: "query_by_key", children: [], values: [], partyMeta };
859
+ }
860
+ /**
861
+ * Decode a base64 `PreparedTransaction` into the bits we validate.
862
+ *
863
+ * Enumerates EVERY node in DamlTransaction.nodes — every node-version member and
864
+ * every node-type oneof member — recording unrecognized ones as
865
+ * present-but-unvalidated (never silently skipping them). The single allowed
866
+ * root exercise, no-orphan reachability, and the all-nodes value backstop are
867
+ * enforced by the per-arm invariant (`assertSingleAllowedRootExercise`).
868
+ */
869
+ export function decodePrepared(preparedTransactionB64) {
870
+ let bytes;
871
+ try {
872
+ bytes = Buffer.from(preparedTransactionB64, "base64");
873
+ }
874
+ catch {
875
+ throw new PreparedDecodeError("preparedTransaction is not valid base64");
876
+ }
877
+ if (bytes.length === 0)
878
+ throw new PreparedDecodeError("preparedTransaction decoded to empty bytes");
879
+ const top = decodeMessage(bytes);
880
+ // metadata.submitter_info.act_as. `transaction` and `metadata` are
881
+ // non-repeated: a duplicate would let a last-wins participant read a different
882
+ // one than we validate, so reject duplicates (act_as / nodes stay repeated).
883
+ const actAs = [];
884
+ let synchronizerId;
885
+ let preparationTime;
886
+ let minLedgerEffectiveTime;
887
+ let maxLedgerEffectiveTime;
888
+ let maxRecordTime;
889
+ const inputContractValues = [];
890
+ const inputContractPartyMeta = [];
891
+ const metadata = lenFieldUnique(top, PT_METADATA, "PreparedTransaction.metadata");
892
+ if (metadata !== undefined) {
893
+ const md = decodeMessage(metadata);
894
+ const si = lenFieldUnique(md, MD_SUBMITTER_INFO, "Metadata.submitter_info");
895
+ if (si !== undefined) {
896
+ for (const a of lenFields(decodeMessage(si), SI_ACT_AS))
897
+ actAs.push(utf8(a));
898
+ }
899
+ // synchronizer_id (field 3) — SIGNED; pinned to caller intent by the arms.
900
+ const sync = lenFieldUnique(md, MD_SYNCHRONIZER_ID, "Metadata.synchronizer_id");
901
+ if (sync !== undefined)
902
+ synchronizerId = utf8(sync);
903
+ // SIGNED timing fields (uint64 µs). Read BigInt-precise; the arms sanity-bound.
904
+ preparationTime = varintFieldUnique(md, MD_PREPARATION_TIME, "Metadata.preparation_time");
905
+ minLedgerEffectiveTime = varintFieldUnique(md, MD_MIN_LEDGER_EFFECTIVE_TIME, "Metadata.min_ledger_effective_time");
906
+ maxLedgerEffectiveTime = varintFieldUnique(md, MD_MAX_LEDGER_EFFECTIVE_TIME, "Metadata.max_ledger_effective_time");
907
+ maxRecordTime = varintFieldUnique(md, MD_MAX_RECORD_TIME, "Metadata.max_record_time");
908
+ // input_contracts (field 7, repeated, SIGNED): descend each InputContract's
909
+ // Create v1 argument so the foreign-party backstop covers parties that appear
910
+ // ONLY inside the authenticated input-contract set (never in a tx node). We
911
+ // ALSO read the Create's signatories/stakeholders/key — the V2 metadata hasher
912
+ // binds disclosed/input contracts via hashNode(toCreateNode) → addCreateNode,
913
+ // which hashes argument + signatories + stakeholders, so those party fields are
914
+ // covered by the agent's signature and a foreign party placed ONLY there must
915
+ // surface to the backstop (closes the input-contract party-coverage asymmetry).
916
+ for (const ic of lenFields(md, MD_INPUT_CONTRACTS)) {
917
+ const icFields = decodeMessage(ic);
918
+ const v1create = lenFieldUnique(icFields, IC_V1, "InputContract.v1");
919
+ if (v1create === undefined) {
920
+ // The InputContract sets no known `contract` oneof member (v1 = field 1).
921
+ // It is carried under an unknown/future encoding whose Create argument we
922
+ // cannot scan — fail closed (mirror the recognized=false treatment of
923
+ // transaction nodes). A foreign party hidden in such an input contract
924
+ // would otherwise escape the all-nodes backstop. (created_at/event_blob
925
+ // are high-numbered non-oneof fields, so an honest InputContract always
926
+ // carries the v1 member here.)
927
+ throw new PreparedDecodeError("Metadata.input_contracts entry sets no known `contract` member (expected v1) — " +
928
+ "refusing to sign (uninspectable input contract that could carry a foreign party)");
929
+ }
930
+ const createFields = decodeMessage(v1create);
931
+ const arg = lenFieldUnique(createFields, CREATE_ARGUMENT, "InputContract.v1.argument");
932
+ if (arg !== undefined)
933
+ inputContractValues.push(arg);
934
+ inputContractPartyMeta.push(...stringFields(createFields, CREATE_SIGNATORIES));
935
+ inputContractPartyMeta.push(...stringFields(createFields, CREATE_STAKEHOLDERS));
936
+ const icKey = lenFieldUnique(createFields, CREATE_KEY, "InputContract.v1.key");
937
+ if (icKey !== undefined) {
938
+ collectGlobalKeyWithMaintainersParties(icKey, inputContractPartyMeta, 0);
939
+ }
940
+ }
941
+ }
942
+ const transaction = lenFieldUnique(top, PT_TRANSACTION, "PreparedTransaction.transaction");
943
+ if (transaction === undefined) {
944
+ throw new PreparedDecodeError("PreparedTransaction has no transaction");
945
+ }
946
+ const dt = decodeMessage(transaction);
947
+ const roots = lenFields(dt, DT_ROOTS).map(utf8);
948
+ const nodes = [];
949
+ const exercises = [];
950
+ for (const node of lenFields(dt, DT_NODES)) {
951
+ const n = decodeMessage(node);
952
+ // node_id is non-repeated; reject a duplicate (a last-wins parser could
953
+ // disagree about which id this node carries, breaking roots/children
954
+ // reachability matching).
955
+ const nodeIdBytes = lenFieldUnique(n, NODE_ID, "Node.node_id");
956
+ const nodeId = nodeIdBytes !== undefined ? utf8(nodeIdBytes) : "";
957
+ // Versioned-node oneof: the ONLY known member is v1 (1000). If v1 is absent
958
+ // OR any OTHER length-delimited member (beyond node_id) is present, the node
959
+ // is carried under a version we cannot read → record it unrecognized so the
960
+ // invariant rejects it (closes the "hidden under node-version 1001" bypass).
961
+ const v1 = lenFieldUnique(n, NODE_V1, "Node.v1");
962
+ const extraVersioned = n.some((f) => f.wire === WIRE_LEN && f.field !== NODE_ID && f.field !== NODE_V1);
963
+ if (v1 === undefined || extraVersioned) {
964
+ nodes.push({ nodeId, recognizedVersion: false, children: [], values: [], partyMeta: [] });
965
+ continue;
966
+ }
967
+ const dv = decodeV1Node(v1);
968
+ nodes.push({
969
+ nodeId,
970
+ recognizedVersion: true,
971
+ kind: dv.kind,
972
+ exercise: dv.exercise,
973
+ children: dv.children,
974
+ values: dv.values,
975
+ partyMeta: dv.partyMeta,
976
+ });
977
+ if (dv.kind === "exercise" && dv.exercise !== undefined)
978
+ exercises.push(dv.exercise);
979
+ }
980
+ return {
981
+ actAs,
982
+ roots,
983
+ nodes,
984
+ exercises,
985
+ synchronizerId,
986
+ preparationTime,
987
+ minLedgerEffectiveTime,
988
+ maxLedgerEffectiveTime,
989
+ maxRecordTime,
990
+ inputContractValues,
991
+ inputContractPartyMeta,
992
+ };
993
+ }
994
+ /* ────────────────────────────────────────────────────────────────────────
995
+ * Shared node-traversal INVARIANT (fix for the fund-redirection bypass).
996
+ *
997
+ * The strong typed-decode core proves the matched exercise's args equal intent.
998
+ * But a signature authorizes the WHOLE DamlTransaction, so verify must also
999
+ * prove there is NOTHING ELSE in the transaction. This invariant, enforced by
1000
+ * BOTH the cip56 and v1 arms, requires fail-closed (in this order — the first
1001
+ * three preserve the long-standing per-arm rejection messages, the rest close
1002
+ * the newly-found node-traversal bypasses):
1003
+ *
1004
+ * 1. EXACTLY ONE exercise of the allowed choice, and NO other exercise of any
1005
+ * choice (extra-leg / wrong-choice / no-exercise contract — unchanged).
1006
+ * 2. EVERY node is recognized — known node version (v1) AND a single known
1007
+ * node type. Any unrecognized version or unknown/ambiguous node type ⇒
1008
+ * reject (closes the non-1000 node-version and unknown-node-type bypasses).
1009
+ * 3. EXACTLY ONE root, and that root node is THE single allowed exercise with
1010
+ * the expected choice id. >1 root, or a root that is not that exercise ⇒
1011
+ * reject (closes the sibling-Create / second-root extra-leg bypass).
1012
+ * 4. NO ORPHANS — every other node is reachable from the root via the
1013
+ * exercise/rollback children chain. An unreferenced sibling node (even if
1014
+ * `roots` lists only the honest id) ⇒ reject.
1015
+ *
1016
+ * The authoritative-submitter (act_as) check and the position-aware all-message
1017
+ * value backstop are applied by the arm AFTER its money-critical field comparison
1018
+ * (so a consistent-attacker tx still surfaces as a field mismatch first), via
1019
+ * `assertActAsIsSender` / `collectSplitPartyLeaves` + `assertNoForeignParties`.
1020
+ *
1021
+ * Returns the single validated root exercise (and its node id, for the
1022
+ * position-aware backstop) so the arm can extract+compare its money-critical
1023
+ * fields. `allowedChoiceId` is the only choice the root may be.
1024
+ */
1025
+ function assertSingleAllowedRootExercise(decoded, allowedChoiceId,
1026
+ /** Human-friendly plural ("transfer" / the choice id) used ONLY in the
1027
+ * exercise-count messages so each arm keeps its historical wording. */
1028
+ countNoun = allowedChoiceId) {
1029
+ // (1) Exercise-count contract (unchanged messages): exactly one exercise of
1030
+ // the allowed choice, and no other exercise of any choice. This runs first so
1031
+ // an extra/duplicate/wrong-choice exercise surfaces with the historical
1032
+ // message before the structural (root/orphan) checks below.
1033
+ const allowedExercises = decoded.exercises.filter((e) => e.choiceId === allowedChoiceId);
1034
+ if (allowedExercises.length === 0) {
1035
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains no ${allowedChoiceId} exercise — refusing to sign ` +
1036
+ `(possible tampered/compromised relay)`);
1037
+ }
1038
+ if (allowedExercises.length > 1) {
1039
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains ${allowedExercises.length} ${countNoun} ` +
1040
+ `exercises — refusing to sign (possible tampered/compromised relay adding a second leg)`);
1041
+ }
1042
+ const otherExercises = decoded.exercises.filter((e) => e.choiceId !== allowedChoiceId);
1043
+ if (otherExercises.length > 0) {
1044
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains unexpected exercise(s) ` +
1045
+ `${otherExercises.map((e) => JSON.stringify(e.choiceId)).join(", ")} — refusing to sign`);
1046
+ }
1047
+ // (2) Every node must be fully recognized. An unrecognized node version or an
1048
+ // unknown/ambiguous node type is opaque — a participant could execute it under
1049
+ // our signature — so fail closed.
1050
+ for (const node of decoded.nodes) {
1051
+ if (!node.recognizedVersion) {
1052
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) carried ` +
1053
+ `under an unrecognized node version — refusing to sign (cannot prove it does not move ` +
1054
+ `value under the agent's authority)`);
1055
+ }
1056
+ if (node.kind === undefined) {
1057
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) with an ` +
1058
+ `unknown or ambiguous node type — refusing to sign`);
1059
+ }
1060
+ // A node typed as an exercise whose choice_id/chosen_value we could NOT fully
1061
+ // decode (DecodedExercise undefined) is an OPAQUE exercise: it is invisible to
1062
+ // the exercise-count checks above (which key off decoded.exercises, populated
1063
+ // only for fully-decoded exercises) yet a participant could run it under the
1064
+ // agent's single signature. Fail closed — we cannot prove it is the single
1065
+ // allowed transfer. (Closes the exercise-count blind spot where a sibling
1066
+ // exercise sets the exercise oneof + choice_id but OMITS chosen_value.)
1067
+ if (node.kind === "exercise" && node.exercise === undefined) {
1068
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains an exercise node (id ${JSON.stringify(node.nodeId)}) ` +
1069
+ `whose choice argument could not be decoded (missing choice_id/chosen_value) — refusing ` +
1070
+ `to sign (cannot prove it is the intended ${JSON.stringify(allowedChoiceId)} exercise)`);
1071
+ }
1072
+ }
1073
+ // (3) Exactly one root, and it must resolve to the single allowed exercise.
1074
+ if (decoded.roots.length !== 1) {
1075
+ throw new PreparedTransferMismatchError(`relay-prepared transaction has ${decoded.roots.length} root nodes — expected exactly one ` +
1076
+ `(${JSON.stringify(allowedChoiceId)}) — refusing to sign (possible tampered/compromised ` +
1077
+ `relay adding a second root leg)`);
1078
+ }
1079
+ const byId = new Map();
1080
+ for (const node of decoded.nodes) {
1081
+ if (byId.has(node.nodeId)) {
1082
+ throw new PreparedTransferMismatchError(`relay-prepared transaction has duplicate node id ${JSON.stringify(node.nodeId)} — ` +
1083
+ `refusing to sign (ambiguous node graph)`);
1084
+ }
1085
+ byId.set(node.nodeId, node);
1086
+ }
1087
+ const rootId = decoded.roots[0];
1088
+ const rootNode = byId.get(rootId);
1089
+ if (rootNode === undefined) {
1090
+ throw new PreparedTransferMismatchError(`relay-prepared transaction root id ${JSON.stringify(rootId)} does not match any node — ` +
1091
+ `refusing to sign`);
1092
+ }
1093
+ if (rootNode.kind !== "exercise" || rootNode.exercise === undefined) {
1094
+ throw new PreparedTransferMismatchError(`relay-prepared transaction root node is not a ${JSON.stringify(allowedChoiceId)} exercise ` +
1095
+ `(got node type ${JSON.stringify(rootNode.kind ?? "unknown")}) — refusing to sign`);
1096
+ }
1097
+ if (rootNode.exercise.choiceId !== allowedChoiceId) {
1098
+ throw new PreparedTransferMismatchError(`relay-prepared transaction root exercise is ${JSON.stringify(rootNode.exercise.choiceId)} — ` +
1099
+ `expected ${JSON.stringify(allowedChoiceId)} — refusing to sign`);
1100
+ }
1101
+ // (4) No orphans: every node must be reachable from the single root via the
1102
+ // children chain. An unreferenced sibling (the extra-leg vector) ⇒ reject.
1103
+ const reachable = new Set();
1104
+ const stack = [rootId];
1105
+ while (stack.length > 0) {
1106
+ const id = stack.pop();
1107
+ if (reachable.has(id))
1108
+ continue;
1109
+ reachable.add(id);
1110
+ const node = byId.get(id);
1111
+ if (node === undefined) {
1112
+ throw new PreparedTransferMismatchError(`relay-prepared transaction references child node id ${JSON.stringify(id)} that does not ` +
1113
+ `exist — refusing to sign`);
1114
+ }
1115
+ for (const child of node.children)
1116
+ stack.push(child);
1117
+ }
1118
+ const orphans = decoded.nodes.filter((nd) => !reachable.has(nd.nodeId));
1119
+ if (orphans.length > 0) {
1120
+ throw new PreparedTransferMismatchError(`relay-prepared transaction contains ${orphans.length} node(s) not reachable from the root ` +
1121
+ `(ids ${orphans.map((o) => JSON.stringify(o.nodeId)).join(", ")}) — refusing to sign ` +
1122
+ `(possible tampered/compromised relay hiding an extra leg)`);
1123
+ }
1124
+ return { exercise: rootNode.exercise, rootNodeId: rootId };
1125
+ }
1126
+ /* ────────────────────────────────────────────────────────────────────────
1127
+ * Shared SIGNED-Metadata checks (synchronizer + timing). Every field below is
1128
+ * in the proto's "Metadata information that needs to be signed" block, so the
1129
+ * agent's single signature authorizes them; verify must constrain the ones that
1130
+ * decide WHERE (which synchronizer) and WHEN (validity window) the transfer
1131
+ * lands, or a malicious relay can land the agent's signature on a wrong domain
1132
+ * or with an already-lapsed / implausibly-skewed validity window.
1133
+ * ──────────────────────────────────────────────────────────────────────── */
1134
+ /** Pin Metadata.synchronizer_id to caller intent when the caller supplies one.
1135
+ * Fail-closed: if pinned but absent/different, refuse. (No pin ⇒ unchanged.) */
1136
+ function assertSynchronizerMatches(synchronizerId, expected) {
1137
+ if (expected === undefined)
1138
+ return; // caller did not pin — unchanged behaviour
1139
+ if (synchronizerId === undefined) {
1140
+ throw new PreparedTransferMismatchError(`relay-prepared transaction has no synchronizer_id but caller intent pins ` +
1141
+ `${JSON.stringify(expected)} — refusing to sign`);
1142
+ }
1143
+ if (synchronizerId !== expected) {
1144
+ throw new PreparedTransferMismatchError(`relay-prepared transaction synchronizer_id is ${JSON.stringify(synchronizerId)} — expected ` +
1145
+ `${JSON.stringify(expected)} — refusing to sign (relay-chosen synchronizer/domain)`);
1146
+ }
1147
+ }
1148
+ /** A generous skew (24h) for sanity-bounding the SIGNED Metadata timing fields.
1149
+ * We are not pinning these to a tight window (the participant enforces the real
1150
+ * ledger-time window); we only reject values that are clearly wrong — already
1151
+ * lapsed, or implausibly far from now — so the agent never blind-signs a command
1152
+ * that can never settle or carries a wildly-skewed timestamp. */
1153
+ const TIMING_SKEW_MS = 24 * 60 * 60 * 1000;
1154
+ /** Microseconds-since-epoch BigInt → milliseconds Number (NaN if not finite). */
1155
+ function microsToMs(micros) {
1156
+ if (micros === undefined)
1157
+ return NaN;
1158
+ try {
1159
+ return Number(micros / 1000n);
1160
+ }
1161
+ catch {
1162
+ return NaN;
1163
+ }
1164
+ }
1165
+ /**
1166
+ * Sanity-bound the SIGNED Metadata timing fields against `nowMs`:
1167
+ * - preparation_time must be within ±TIMING_SKEW of now (not wildly skewed);
1168
+ * - max_record_time / max_ledger_effective_time must not already be in the past
1169
+ * (an expired command can never settle — at best a DoS, at worst replays a
1170
+ * stale intent). These are best-effort: absent fields are skipped.
1171
+ */
1172
+ function assertTimingPlausible(decoded, nowMs) {
1173
+ const prep = microsToMs(decoded.preparationTime);
1174
+ if (Number.isFinite(prep)) {
1175
+ if (prep > nowMs + TIMING_SKEW_MS || prep < nowMs - TIMING_SKEW_MS) {
1176
+ throw new PreparedTransferMismatchError(`relay-prepared transaction preparation_time (${new Date(prep).toISOString()}) is ` +
1177
+ `implausibly far from now — refusing to sign`);
1178
+ }
1179
+ }
1180
+ const maxRecord = microsToMs(decoded.maxRecordTime);
1181
+ if (Number.isFinite(maxRecord) && maxRecord <= nowMs) {
1182
+ throw new PreparedTransferMismatchError(`relay-prepared transaction max_record_time (${new Date(maxRecord).toISOString()}) is ` +
1183
+ `already in the past — refusing to sign (command could never be recorded)`);
1184
+ }
1185
+ const maxLet = microsToMs(decoded.maxLedgerEffectiveTime);
1186
+ if (Number.isFinite(maxLet) && maxLet <= nowMs) {
1187
+ throw new PreparedTransferMismatchError(`relay-prepared transaction max_ledger_effective_time (${new Date(maxLet).toISOString()}) ` +
1188
+ `is already in the past — refusing to sign`);
1189
+ }
1190
+ // min_ledger_effective_time is SIGNED and was DECODED but previously not bounded
1191
+ // (an asymmetry vs the fields above). A relay-chosen value implausibly far in
1192
+ // the future would make the agent sign a command pinned to an unreachable
1193
+ // validity window (it can never become ledger-valid before it expires). Reject
1194
+ // it like the others; near-now / absent values pass.
1195
+ const minLet = microsToMs(decoded.minLedgerEffectiveTime);
1196
+ if (Number.isFinite(minLet) && minLet > nowMs + TIMING_SKEW_MS) {
1197
+ throw new PreparedTransferMismatchError(`relay-prepared transaction min_ledger_effective_time (${new Date(minLet).toISOString()}) ` +
1198
+ `is implausibly far in the future — refusing to sign (command pinned to an unreachable ` +
1199
+ `validity window)`);
1200
+ }
1201
+ }
1202
+ /**
1203
+ * Pin WHICH template the validated root exercise runs against. The choice NAME
1204
+ * and ARGUMENT are pinned elsewhere; this closes the template/contract-confusion
1205
+ * surface by additionally requiring the exercised template's `module:entity`
1206
+ * qualified name to equal caller intent (the package id is intentionally not
1207
+ * pinned — it changes across upgrades). No pin ⇒ unchanged behaviour.
1208
+ */
1209
+ function assertTemplateMatches(ex, expected) {
1210
+ if (expected === undefined)
1211
+ return;
1212
+ if (ex.templateQualifiedName === undefined) {
1213
+ throw new PreparedTransferMismatchError(`relay-prepared exercise carries no decodable template_id but caller intent pins ` +
1214
+ `${JSON.stringify(expected)} — refusing to sign`);
1215
+ }
1216
+ if (ex.templateQualifiedName !== expected) {
1217
+ throw new PreparedTransferMismatchError(`relay-prepared exercise runs against template ${JSON.stringify(ex.templateQualifiedName)} — ` +
1218
+ `expected ${JSON.stringify(expected)} — refusing to sign (template/contract confusion)`);
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Pin WHICH contract the validated root exercise is exercised on
1223
+ * (Exercise.contract_id). Defense-in-depth, OPT-IN: when the caller supplies the
1224
+ * exact target cid it built the command against (the factory / EPAR contract id
1225
+ * the relay resolved to it), the verifier requires the prepared exercise to
1226
+ * target the SAME contract and fails closed otherwise — closing the
1227
+ * resolve→prepare TOCTOU where a relay resolves one contract to the agent but
1228
+ * prepares the exercise against another. No pin ⇒ unchanged behaviour (the
1229
+ * all-nodes party backstop still contains any fund redirect a substituted target
1230
+ * could attempt, since the redirect needs a foreign-party consequence).
1231
+ */
1232
+ function assertContractIdMatches(ex, expected) {
1233
+ if (expected === undefined)
1234
+ return;
1235
+ if (ex.contractId === undefined) {
1236
+ throw new PreparedTransferMismatchError(`relay-prepared exercise carries no decodable contract_id but caller intent pins ` +
1237
+ `${JSON.stringify(expected)} — refusing to sign`);
1238
+ }
1239
+ if (ex.contractId !== expected) {
1240
+ throw new PreparedTransferMismatchError(`relay-prepared exercise targets contract ${JSON.stringify(ex.contractId)} — ` +
1241
+ `expected ${JSON.stringify(expected)} — refusing to sign ` +
1242
+ `(relay points the choice at a contract other than the one it resolved)`);
1243
+ }
1244
+ }
1245
+ /**
1246
+ * Collect party leaves across the WHOLE signed message, split by position
1247
+ * relative to the validated root exercise (`rootNodeId`). Reuses the strong,
1248
+ * fail-closed `collectPartyLeaves` primitive on each Value payload, and adds the
1249
+ * node-level party-metadata strings + Metadata.input_contracts Create arguments
1250
+ * so a party introduced anywhere the agent's signature covers is visible.
1251
+ */
1252
+ function collectSplitPartyLeaves(decoded, rootNodeId) {
1253
+ const rootArg = [];
1254
+ const elsewhere = [];
1255
+ for (const node of decoded.nodes) {
1256
+ const isRoot = node.nodeId === rootNodeId;
1257
+ if (isRoot && node.exercise !== undefined) {
1258
+ // The root exercise's chosen_value is the only place the admin/dso may sit
1259
+ // at its pinned position; its exercise_result (also in node.values) is a
1260
+ // consequence, so treat it as "elsewhere".
1261
+ collectPartyLeaves(node.exercise.chosenValue, rootArg, 0);
1262
+ for (const value of node.values) {
1263
+ if (value !== node.exercise.chosenValue)
1264
+ collectPartyLeaves(value, elsewhere, 0);
1265
+ }
1266
+ }
1267
+ else {
1268
+ for (const value of node.values)
1269
+ collectPartyLeaves(value, elsewhere, 0);
1270
+ }
1271
+ // Node-level party metadata (authorization/visibility parties) — for ALL
1272
+ // nodes including the root — are never a place the relay may introduce an
1273
+ // unexpected party, so they go in `elsewhere`.
1274
+ for (const p of node.partyMeta)
1275
+ elsewhere.push(p);
1276
+ }
1277
+ // Metadata.input_contracts Create arguments (SIGNED): any party here is an
1278
+ // input-contract payload party — not a place a new recipient legitimately
1279
+ // appears, so scan it under `elsewhere` too.
1280
+ for (const value of decoded.inputContractValues)
1281
+ collectPartyLeaves(value, elsewhere, 0);
1282
+ // Metadata.input_contracts Create signatories/stakeholders/key (also SIGNED via
1283
+ // the V2 metadata hasher's disclosed-contract hashNode): same treatment.
1284
+ for (const p of decoded.inputContractPartyMeta)
1285
+ elsewhere.push(p);
1286
+ return { rootArg, elsewhere };
1287
+ }
1288
+ /**
1289
+ * The foreign-party backstop, position-aware so a relay-supplied admin/dso can
1290
+ * never widen the recipient set across the whole transaction.
1291
+ *
1292
+ * @param allowed {sender, receiver, (delegate)} — recipients pinned to intent.
1293
+ * @param adminDso the admin/dso value read at its OWN root position (or undefined).
1294
+ * @param adminDsoRootMax how many times `adminDso` may legitimately appear in the
1295
+ * root chosen_value (cip56: 2 = expectedAdmin + instrumentId.admin;
1296
+ * v1: 1 = expectedDso).
1297
+ * @param adminDsoTrusted true iff `adminDso` was pinned to an independently-trusted
1298
+ * caller value — only then is it safe to value-exclude it
1299
+ * OUTSIDE its root position (consequence/meta/input nodes).
1300
+ */
1301
+ function assertNoForeignParties(leaves, allowed, adminDso, adminDsoRootMax, adminDsoTrusted) {
1302
+ const foreign = [];
1303
+ // Root chosen_value: allow recipients freely; allow adminDso up to its known
1304
+ // count at its pinned position(s); a SECOND/extra occurrence (e.g. a smuggled
1305
+ // extra recipient leaf that happens to equal the admin/dso value) is foreign.
1306
+ let adminDsoBudget = adminDso !== undefined ? adminDsoRootMax : 0;
1307
+ for (const p of leaves.rootArg) {
1308
+ if (allowed.has(p))
1309
+ continue;
1310
+ if (adminDso !== undefined && p === adminDso && adminDsoBudget > 0) {
1311
+ adminDsoBudget--;
1312
+ continue;
1313
+ }
1314
+ foreign.push(p);
1315
+ }
1316
+ // Everywhere else (consequence/sibling node Value payloads, ANY node's party
1317
+ // metadata, Metadata.input_contracts arguments): allow recipients freely; allow
1318
+ // adminDso ONLY when it is independently TRUSTED (caller-pinned to an out-of-band
1319
+ // value). A relay-chosen, UNPINNED admin/dso that reappears outside its root
1320
+ // position is treated as FOREIGN — this closes the "alias the unpinned
1321
+ // admin/dso to an attacker and inject that same value as a consequence /
1322
+ // node-metadata / input-contract party" neutralization (the relay controls the
1323
+ // unpinned value, so value-excluding it anywhere would whitelist the attacker
1324
+ // globally). The honest consequence legitimately carries the real DSO as a
1325
+ // payload party + signatory, so a value-moving caller MUST supply a trusted
1326
+ // expectedDso/instrumentAdmin (out-of-band; tx.ts plumbs it from
1327
+ // CANTON_AGENT_DSO_PARTY) — without it we fail closed here rather than trust a
1328
+ // relay-supplied value. The previous no-trusted-pin value-global fallback is
1329
+ // REMOVED: it was exactly the residual neutralization an adversary exploits.
1330
+ for (const p of leaves.elsewhere) {
1331
+ if (allowed.has(p))
1332
+ continue;
1333
+ if (adminDso !== undefined && adminDsoTrusted && p === adminDso)
1334
+ continue;
1335
+ foreign.push(p);
1336
+ }
1337
+ if (foreign.length > 0) {
1338
+ const uniq = [...new Set(foreign)];
1339
+ throw new PreparedTransferMismatchError(`relay-prepared transaction references unexpected part${uniq.length === 1 ? "y" : "ies"} ` +
1340
+ `${uniq.map((f) => JSON.stringify(f)).join(", ")} — refusing to sign ` +
1341
+ `(possible tampered/compromised relay redirecting funds)`);
1342
+ }
1343
+ }
1344
+ /**
1345
+ * Cross-check the authoritative submitter (Metadata.submitter_info.act_as):
1346
+ * REQUIRE a non-empty act_as whose only party is the sender. The non-empty
1347
+ * requirement closes GAP A3 (an empty act_as previously skipped this check
1348
+ * silently). Called by both arms AFTER the money-critical field comparison so a
1349
+ * consistent-attacker transaction surfaces as a field mismatch first.
1350
+ */
1351
+ function assertActAsIsSender(actAs, sender) {
1352
+ if (actAs.length === 0) {
1353
+ throw new PreparedTransferMismatchError("relay-prepared transaction has an empty act_as (authoritative submitter) — " +
1354
+ "refusing to sign (the submitter must be positively proven to be the agent)");
1355
+ }
1356
+ const unexpected = actAs.filter((p) => p !== sender);
1357
+ if (unexpected.length > 0 || !actAs.includes(sender)) {
1358
+ throw new PreparedTransferMismatchError(`relay-prepared transaction acts as ${JSON.stringify(actAs)} — expected only ` +
1359
+ `${JSON.stringify(sender)} — refusing to sign`);
1360
+ }
1361
+ }
1362
+ /**
1363
+ * Read a money-critical record field BY ITS DAML DECLARATION-ORDER POSITION,
1364
+ * cross-checking labels — the fix for the label-vs-positional binding divergence
1365
+ * (amount inflation / receiver swap via relabeled/reordered fields).
1366
+ *
1367
+ * WHY POSITIONAL: a Daml-LF record is POSITIONAL. The participant binds the
1368
+ * choice argument by the choice type's field ORDER; the wire `RecordField.label`
1369
+ * is advisory. A reader that prefers LABELS can be made to diverge from the
1370
+ * engine: a malicious relay places the honest value at a field LABELED "amount"
1371
+ * but at a wire position the engine does NOT read as amount, and an INFLATED
1372
+ * Numeric at the wire position the engine binds as amount (under a junk label) —
1373
+ * a by-label read sees the honest decoy and passes while the engine moves the
1374
+ * inflated amount. We therefore read the field at its declaration-order
1375
+ * `position` (what the engine reads) and additionally FAIL CLOSED on any
1376
+ * label/position inconsistency:
1377
+ * (a) the entry AT `position`, if labelled, must carry exactly `label`; and
1378
+ * (b) NO OTHER entry may carry `label` (a decoy field re-using a money-critical
1379
+ * label at the wrong position).
1380
+ * Honest encodings — fully labelled in declaration order, OR label-free
1381
+ * (normalized) — both pass; only a label/position-divergent record is rejected.
1382
+ * `expectedAt` lets the caller reject a field that the engine would bind at a
1383
+ * position the record does not even have (arity check for that field).
1384
+ */
1385
+ function entryByDeclOrder(entries, position, label) {
1386
+ // (b) a money-critical label may appear at most once, and only at its own
1387
+ // declaration-order position. A decoy field re-using the label elsewhere is a
1388
+ // divergence attempt — fail closed.
1389
+ for (let i = 0; i < entries.length; i++) {
1390
+ if (i !== position && entries[i]?.label === label) {
1391
+ throw new PreparedDecodeError(`record field labelled ${JSON.stringify(label)} appears at position ${i} but the Daml ` +
1392
+ `declaration order binds it at position ${position} — refusing to sign ` +
1393
+ `(label/position divergence, possible amount/receiver tamper)`);
1394
+ }
1395
+ }
1396
+ const e = entries[position];
1397
+ if (e === undefined)
1398
+ return undefined;
1399
+ // (a) the entry the engine binds at `position`, if labelled, must be THIS field.
1400
+ if (e.label !== "" && e.label !== label) {
1401
+ throw new PreparedDecodeError(`record field at Daml declaration position ${position} is labelled ${JSON.stringify(e.label)} ` +
1402
+ `but ${JSON.stringify(label)} is expected there — refusing to sign ` +
1403
+ `(label/position divergence, possible amount/receiver tamper)`);
1404
+ }
1405
+ return e;
1406
+ }
1407
+ /** Read instrumentId {admin, id} from a record entry that is itself a record.
1408
+ * Declaration order: [0] admin:Party [1] id:Text. Read positionally with the
1409
+ * same label/position-divergence guard as the transfer fields. */
1410
+ function readInstrument(value) {
1411
+ const entries = recordEntries(value);
1412
+ const adminE = entryByDeclOrder(entries, 0, "admin");
1413
+ const idE = entryByDeclOrder(entries, 1, "id");
1414
+ const adminLeaf = adminE ? leafOf(adminE.value) : undefined;
1415
+ const idLeaf = idE ? leafOf(idE.value) : undefined;
1416
+ if (!adminLeaf || adminLeaf.kind !== "party") {
1417
+ throw new PreparedDecodeError("instrumentId.admin is not a party");
1418
+ }
1419
+ if (!idLeaf || idLeaf.kind !== "text") {
1420
+ throw new PreparedDecodeError("instrumentId.id is not text");
1421
+ }
1422
+ return { admin: adminLeaf.value, id: idLeaf.value };
1423
+ }
1424
+ /**
1425
+ * Extract the transfer body (sender/receiver/amount/instrument) from a
1426
+ * TransferFactory_Transfer choice argument, BY TYPE at its structural position.
1427
+ */
1428
+ export function extractTransfer(chosenValue) {
1429
+ const top = recordEntries(chosenValue);
1430
+ // Locate the `transfer` sub-record STRICTLY at its Daml declaration position.
1431
+ // The outer choiceArgument is
1432
+ // [0]expectedAdmin:Party [1]transfer:Record [2]extraArgs:Record
1433
+ // so the participant binds `transfer` at declaration position 1, REGARDLESS of
1434
+ // labels (a Daml-LF record is positional; the wire `RecordField.label` is
1435
+ // advisory). We therefore read position 1 and ONLY position 1, with the same
1436
+ // label/position-divergence guard the money fields use.
1437
+ //
1438
+ // We deliberately do NOT fall back to a shape-based search ("find the first
1439
+ // record that looks like a transfer"): that override was a NON-positional read
1440
+ // that a malicious relay could weaponize. By type-diverging ONE field of the
1441
+ // engine's real position-1 transfer record (e.g. sender as Optional(Party) or
1442
+ // receiver as List(Party)) the relay made it fail the shape heuristic, so the
1443
+ // search returned a relay-planted, well-typed DECOY transfer record at a
1444
+ // DIFFERENT outer position carrying the honest amount — while the engine bound
1445
+ // the inflated amount from position 1. The verifier compared the decoy and
1446
+ // passed. Reading position 1 directly closes that divergence: the entry the
1447
+ // engine binds as `transfer` is the entry we validate, and a type-malformed
1448
+ // transfer record is itself a tamper signal — it fails the per-field type
1449
+ // checks below and we refuse to sign rather than search elsewhere.
1450
+ const transferVal = entryByDeclOrder(top, 1, "transfer")?.value;
1451
+ if (!transferVal)
1452
+ throw new PreparedDecodeError("could not locate transfer record in choice argument");
1453
+ const t = recordEntries(transferVal);
1454
+ // Read each money-critical field at its DAML DECLARATION-ORDER POSITION (what
1455
+ // the participant binds), NOT by label — and fail closed on any label/position
1456
+ // divergence (the amount-inflation / receiver-swap vector). Declaration order:
1457
+ // [0] sender:Party [1] receiver:Party [2] amount:Numeric [3] instrumentId:Record
1458
+ // (trailing requestedAt/executeBefore/inputHoldingCids/meta are not money-
1459
+ // critical here; party leaves in them are still covered by the backstop).
1460
+ const senderE = entryByDeclOrder(t, 0, "sender");
1461
+ const receiverE = entryByDeclOrder(t, 1, "receiver");
1462
+ const amountE = entryByDeclOrder(t, 2, "amount");
1463
+ const instrE = entryByDeclOrder(t, 3, "instrumentId");
1464
+ const sender = senderE ? leafOf(senderE.value) : undefined;
1465
+ const receiver = receiverE ? leafOf(receiverE.value) : undefined;
1466
+ const amount = amountE ? leafOf(amountE.value) : undefined;
1467
+ const instrumentVal = instrE?.value;
1468
+ if (!sender || sender.kind !== "party")
1469
+ throw new PreparedDecodeError("transfer.sender is not a party");
1470
+ if (!receiver || receiver.kind !== "party")
1471
+ throw new PreparedDecodeError("transfer.receiver is not a party");
1472
+ if (!amount || amount.kind !== "numeric")
1473
+ throw new PreparedDecodeError("transfer.amount is not numeric");
1474
+ if (!instrumentVal)
1475
+ throw new PreparedDecodeError("transfer.instrumentId missing");
1476
+ const instrument = readInstrument(instrumentVal);
1477
+ return {
1478
+ sender: sender.value,
1479
+ receiver: receiver.value,
1480
+ amount: amount.value,
1481
+ instrumentAdmin: instrument.admin,
1482
+ instrumentId: instrument.id,
1483
+ };
1484
+ }
1485
+ export class PreparedDecodeError extends Error {
1486
+ constructor(message) {
1487
+ super(`preparedTransaction: ${message}`);
1488
+ this.name = "PreparedDecodeError";
1489
+ }
1490
+ }
1491
+ export class PreparedTransferMismatchError extends Error {
1492
+ constructor(message) {
1493
+ super(message);
1494
+ this.name = "PreparedTransferMismatchError";
1495
+ }
1496
+ }
1497
+ /**
1498
+ * Assert the relay-returned `preparedTransaction` encodes EXACTLY the transfer
1499
+ * the agent intended. Throws `PreparedTransferMismatchError` on any mismatch
1500
+ * and `PreparedDecodeError` if the bytes are not a decodable PreparedTransaction
1501
+ * carrying a single transfer exercise. Call this BEFORE signing `hash`.
1502
+ *
1503
+ * Fail-closed: anything we cannot positively prove matches the intent throws.
1504
+ */
1505
+ export function assertPreparedTransferMatches(preparedTransactionB64, expect) {
1506
+ const decoded = decodePrepared(preparedTransactionB64);
1507
+ // Node-traversal invariant (shared with the v1 arm): exactly one allowed
1508
+ // exercise & no other; EVERY node recognized (no node hidden under an unknown
1509
+ // version/type); EXACTLY ONE root that IS the single allowed
1510
+ // TransferFactory_Transfer exercise; no orphan/extra-leg node. Returns the
1511
+ // validated root exercise + its node id (for the position-aware backstop).
1512
+ const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, TRANSFER_CHOICE, "transfer");
1513
+ const t = extractTransfer(ex.chosenValue);
1514
+ const mismatches = [];
1515
+ if (t.sender !== expect.sender) {
1516
+ mismatches.push(`sender (got ${JSON.stringify(t.sender)}, intended ${JSON.stringify(expect.sender)})`);
1517
+ }
1518
+ if (t.receiver !== expect.receiver) {
1519
+ mismatches.push(`receiver (got ${JSON.stringify(t.receiver)}, intended ${JSON.stringify(expect.receiver)})`);
1520
+ }
1521
+ if (t.amount !== expect.amount) {
1522
+ mismatches.push(`amount (got ${JSON.stringify(t.amount)}, intended ${JSON.stringify(expect.amount)})`);
1523
+ }
1524
+ if (t.instrumentId !== expect.instrumentId) {
1525
+ mismatches.push(`instrumentId.id (got ${JSON.stringify(t.instrumentId)}, intended ${JSON.stringify(expect.instrumentId)})`);
1526
+ }
1527
+ // Only pin the admin if the caller supplied an independently-trusted value.
1528
+ if (expect.instrumentAdmin !== undefined && t.instrumentAdmin !== expect.instrumentAdmin) {
1529
+ mismatches.push(`instrumentId.admin (got ${JSON.stringify(t.instrumentAdmin)}, intended ${JSON.stringify(expect.instrumentAdmin)})`);
1530
+ }
1531
+ if (mismatches.length > 0) {
1532
+ throw new PreparedTransferMismatchError(`relay-prepared transfer does not match intent: ${mismatches.join("; ")} — ` +
1533
+ `refusing to sign (possible tampered/compromised relay redirecting funds)`);
1534
+ }
1535
+ // The admin/dso must never be aliased to a money role — otherwise a relay could
1536
+ // set the (unpinned) admin to the receiver/sender and have the backstop exempt
1537
+ // a money party. Reject up front.
1538
+ if (t.instrumentAdmin === expect.sender ||
1539
+ t.instrumentAdmin === expect.receiver) {
1540
+ throw new PreparedTransferMismatchError(`relay-prepared transfer instrumentId.admin ${JSON.stringify(t.instrumentAdmin)} equals a ` +
1541
+ `transfer party (sender/receiver) — refusing to sign`);
1542
+ }
1543
+ // Pin WHICH template + WHICH contract the choice runs against
1544
+ // (template/contract-confusion + resolve→prepare TOCTOU) and the SIGNED
1545
+ // synchronizer + timing metadata (relay-chosen domain / validity window). All
1546
+ // no-ops unless the caller supplies the corresponding intent.
1547
+ assertTemplateMatches(ex, expect.templateQualifiedName);
1548
+ assertContractIdMatches(ex, expect.expectedContractId);
1549
+ assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
1550
+ assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
1551
+ // Cross-check the authoritative submitter: REQUIRE a non-empty act_as whose
1552
+ // only party is the sender (closes GAP A3 — an empty act_as no longer skips
1553
+ // this). Placed after the field comparison so a consistent-attacker tx
1554
+ // surfaces as a field mismatch first.
1555
+ assertActAsIsSender(decoded.actAs, expect.sender);
1556
+ // Position-aware foreign-party backstop over the WHOLE signed message (all node
1557
+ // Value payloads + node-level party metadata + Metadata.input_contracts): no
1558
+ // party other than {sender, receiver} may appear, EXCEPT the instrument admin
1559
+ // (DSO) at its known root positions (expectedAdmin + instrumentId.admin = 2).
1560
+ // The admin is value-excluded OUTSIDE its root position ONLY when pinned to an
1561
+ // independently-trusted value (expect.instrumentAdmin) — so a relay-chosen,
1562
+ // unpinned admin can no longer be aliased to the attacker and smuggled in as a
1563
+ // consequence/metadata recipient.
1564
+ const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
1565
+ assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver]), t.instrumentAdmin, 2 /* expectedAdmin + instrumentId.admin */, expect.instrumentAdmin !== undefined /* trusted iff caller pinned it */);
1566
+ }
1567
+ /* ════════════════════════════════════════════════════════════════════════
1568
+ * v1 (external-party-amulet-rules) — verify-before-sign for the
1569
+ * `ExternalPartyAmuletRules_CreateTransferCommand` exercise.
1570
+ *
1571
+ * THREAT MODEL — identical to the cip56 arm above, for the v1 path. The agent
1572
+ * asks the relay to PREPARE an `ExternalPartyAmuletRules_CreateTransferCommand`
1573
+ * (it creates a `TransferCommand` the facilitator later settles). A compromised
1574
+ * relay could prepare a DIFFERENT command — swap the receiver, inflate the
1575
+ * amount, redirect the delegate to an attacker who can then steer settlement,
1576
+ * or change the sender — and hand back its hash. We decode the prepared bytes
1577
+ * with the SAME structural rigor (schema-pinned field numbers, fail-closed on
1578
+ * duplicate/multi-member oneofs, parties matched BY TYPE not text) and assert
1579
+ * each money-critical field equals CALLER INTENT at its structural position.
1580
+ *
1581
+ * choiceArgument :: Record {
1582
+ * sender : Party, -- MUST == the agent's own party
1583
+ * receiver : Party, -- MUST == merchant payTo from the 402
1584
+ * delegate : Party, -- MUST == facilitatorParty from the 402 extra
1585
+ * amount : Numeric, -- MUST == the required amount
1586
+ * expiresAt : Time, -- sanity-checked (present, plausible)
1587
+ * nonce : Int, -- sanity-checked (>= 0, matches expected)
1588
+ * description : Optional Text, -- not money-critical (facilitator re-validates)
1589
+ * expectedDso : Party -- relay-supplied; NOT trusted as a recipient
1590
+ * }
1591
+ *
1592
+ * We extract sender/receiver/delegate by label, else by Daml declaration order
1593
+ * (parties[0]/[1]/[2]); amount = the single Numeric leaf; nonce = the single
1594
+ * Int64 leaf. Every field read uses the unique accessors so a duplicate/decoy
1595
+ * occurrence fails closed rather than diverging from the participant's
1596
+ * last-wins parse. The foreign-party backstop allows ONLY {sender, receiver,
1597
+ * delegate} plus the expectedDso AT its own position — any other party (an extra
1598
+ * leg, a smuggled GenMap recipient) is rejected. expectedDso is relay-supplied,
1599
+ * so it is allowed positionally but NEVER used to widen the recipient allowlist.
1600
+ * ════════════════════════════════════════════════════════════════════════ */
1601
+ /** The create choice on ExternalPartyAmuletRules (v1 / facilitator-pays-gas). */
1602
+ const CREATE_TRANSFER_COMMAND_CHOICE = "ExternalPartyAmuletRules_CreateTransferCommand";
1603
+ /**
1604
+ * Extract the v1 create-command fields from its (flat) choice-argument record,
1605
+ * BY TYPE at its DAML DECLARATION-ORDER POSITION (what the participant binds),
1606
+ * NOT by label — and fail closed on any label/position divergence (the
1607
+ * amount-inflation / receiver-swap vector). Declaration order:
1608
+ * [0] sender:Party [1] receiver:Party [2] delegate:Party [3] amount:Numeric
1609
+ * [4] expiresAt:Time [5] nonce:Int [6] description:Optional Text [7] expectedDso:Party
1610
+ * Honest encodings — fully labelled in order OR label-free (normalized) — both
1611
+ * pass; a record whose labels disagree with declaration order (a decoy field
1612
+ * re-using a money-critical label, or a mislabeled value at a money position) is
1613
+ * rejected. Fails closed if a money-critical field is the wrong type / missing.
1614
+ */
1615
+ export function extractCreateTransferCommand(chosenValue) {
1616
+ const entries = recordEntries(chosenValue);
1617
+ // ── positional reads at Daml declaration order, with label/position guard ──
1618
+ const senderE = entryByDeclOrder(entries, 0, "sender");
1619
+ const receiverE = entryByDeclOrder(entries, 1, "receiver");
1620
+ const delegateE = entryByDeclOrder(entries, 2, "delegate");
1621
+ const amountE = entryByDeclOrder(entries, 3, "amount");
1622
+ const expiresAtE = entryByDeclOrder(entries, 4, "expiresAt");
1623
+ const nonceE = entryByDeclOrder(entries, 5, "nonce");
1624
+ // position 6 = description:Optional Text — not money-critical; skip.
1625
+ const expectedDsoE = entryByDeclOrder(entries, 7, "expectedDso");
1626
+ const sender = senderE ? leafOf(senderE.value) : undefined;
1627
+ const receiver = receiverE ? leafOf(receiverE.value) : undefined;
1628
+ const delegate = delegateE ? leafOf(delegateE.value) : undefined;
1629
+ const amount = amountE ? leafOf(amountE.value) : undefined;
1630
+ const nonce = nonceE ? leafOf(nonceE.value) : undefined;
1631
+ const expiresAt = expiresAtE ? leafOf(expiresAtE.value) : undefined;
1632
+ // expectedDso is relay-supplied; read at its own position for the backstop
1633
+ // exclusion only, NEVER as a recipient allowlist entry.
1634
+ const expectedDso = expectedDsoE ? leafOf(expectedDsoE.value) : undefined;
1635
+ if (!sender || sender.kind !== "party") {
1636
+ throw new PreparedDecodeError("CreateTransferCommand.sender is not a party");
1637
+ }
1638
+ if (!receiver || receiver.kind !== "party") {
1639
+ throw new PreparedDecodeError("CreateTransferCommand.receiver is not a party");
1640
+ }
1641
+ if (!delegate || delegate.kind !== "party") {
1642
+ throw new PreparedDecodeError("CreateTransferCommand.delegate is not a party");
1643
+ }
1644
+ if (!amount || amount.kind !== "numeric") {
1645
+ throw new PreparedDecodeError("CreateTransferCommand.amount is not numeric");
1646
+ }
1647
+ if (!nonce || nonce.kind !== "int64") {
1648
+ throw new PreparedDecodeError("CreateTransferCommand.nonce is not an int");
1649
+ }
1650
+ return {
1651
+ sender: sender.value,
1652
+ receiver: receiver.value,
1653
+ delegate: delegate.value,
1654
+ amount: amount.value,
1655
+ nonce: nonce.value,
1656
+ ...(expiresAt && expiresAt.kind === "timestamp"
1657
+ ? { expiresAt: expiresAt.value }
1658
+ : {}),
1659
+ ...(expectedDso && expectedDso.kind === "party"
1660
+ ? { expectedDso: expectedDso.value }
1661
+ : {}),
1662
+ };
1663
+ }
1664
+ /**
1665
+ * Assert the relay-returned `preparedTransaction` encodes EXACTLY the v1
1666
+ * `ExternalPartyAmuletRules_CreateTransferCommand` the agent intended. Throws
1667
+ * `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError` if
1668
+ * the bytes are not a decodable PreparedTransaction carrying a single
1669
+ * create-command exercise. Call BEFORE signing the hash. Fail-closed: anything
1670
+ * not positively proven to match the intent throws.
1671
+ */
1672
+ export function assertPreparedCreateTransferCommandMatches(preparedTransactionB64, expect) {
1673
+ const decoded = decodePrepared(preparedTransactionB64);
1674
+ // Node-traversal invariant (shared with the cip56 arm): exactly one allowed
1675
+ // exercise & no other; EVERY node recognized (no node hidden under an unknown
1676
+ // node version/type — closes the "sibling Create" and "node-version 1001"
1677
+ // bypasses); EXACTLY ONE root that IS the single allowed CreateTransferCommand
1678
+ // exercise; no orphan/extra-leg node. Returns the validated root exercise.
1679
+ const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, CREATE_TRANSFER_COMMAND_CHOICE);
1680
+ const c = extractCreateTransferCommand(ex.chosenValue);
1681
+ const mismatches = [];
1682
+ if (c.sender !== expect.sender) {
1683
+ mismatches.push(`sender (got ${JSON.stringify(c.sender)}, intended ${JSON.stringify(expect.sender)})`);
1684
+ }
1685
+ if (c.receiver !== expect.receiver) {
1686
+ mismatches.push(`receiver (got ${JSON.stringify(c.receiver)}, intended ${JSON.stringify(expect.receiver)})`);
1687
+ }
1688
+ if (c.delegate !== expect.delegate) {
1689
+ mismatches.push(`delegate (got ${JSON.stringify(c.delegate)}, intended ${JSON.stringify(expect.delegate)})`);
1690
+ }
1691
+ if (c.amount !== expect.amount) {
1692
+ mismatches.push(`amount (got ${JSON.stringify(c.amount)}, intended ${JSON.stringify(expect.amount)})`);
1693
+ }
1694
+ if (expect.nonce !== undefined && c.nonce !== expect.nonce) {
1695
+ mismatches.push(`nonce (got ${JSON.stringify(c.nonce)}, intended ${JSON.stringify(expect.nonce)})`);
1696
+ }
1697
+ // Pin the DSO only if the caller supplied an independently-trusted value.
1698
+ if (expect.expectedDso !== undefined && c.expectedDso !== expect.expectedDso) {
1699
+ mismatches.push(`expectedDso (got ${JSON.stringify(c.expectedDso)}, intended ${JSON.stringify(expect.expectedDso)})`);
1700
+ }
1701
+ if (mismatches.length > 0) {
1702
+ throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand does not match intent: ` +
1703
+ `${mismatches.join("; ")} — refusing to sign ` +
1704
+ `(possible tampered/compromised relay redirecting funds)`);
1705
+ }
1706
+ // The DSO must never be aliased to a money role — otherwise a relay could set
1707
+ // the (unpinned) expectedDso to the receiver/sender/delegate and have the
1708
+ // backstop exempt a money party. Reject up front.
1709
+ if (c.expectedDso !== undefined &&
1710
+ (c.expectedDso === expect.sender ||
1711
+ c.expectedDso === expect.receiver ||
1712
+ c.expectedDso === expect.delegate)) {
1713
+ throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand expectedDso ${JSON.stringify(c.expectedDso)} equals a ` +
1714
+ `command party (sender/receiver/delegate) — refusing to sign`);
1715
+ }
1716
+ // Pin WHICH template + WHICH contract the choice runs against
1717
+ // (template/contract-confusion + resolve→prepare TOCTOU) and the SIGNED
1718
+ // synchronizer + timing metadata (relay-chosen domain / validity window).
1719
+ // No-ops unless the caller supplies the corresponding intent.
1720
+ assertTemplateMatches(ex, expect.templateQualifiedName);
1721
+ assertContractIdMatches(ex, expect.expectedContractId);
1722
+ assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
1723
+ assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
1724
+ // nonce sanity: a negative nonce is never legitimate (Daml Int can be
1725
+ // negative on the wire, but a TransferCommand nonce is a monotonic counter).
1726
+ let nonceBig;
1727
+ try {
1728
+ nonceBig = BigInt(c.nonce);
1729
+ }
1730
+ catch {
1731
+ throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand has a non-integer nonce ` +
1732
+ `${JSON.stringify(c.nonce)} — refusing to sign`);
1733
+ }
1734
+ if (nonceBig < 0n) {
1735
+ throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand has a negative nonce ` +
1736
+ `${JSON.stringify(c.nonce)} — refusing to sign`);
1737
+ }
1738
+ // expiresAt sanity (best-effort): when present as a timestamp leaf, reject a
1739
+ // value already in the past — a relay-set expiry that has lapsed would create
1740
+ // a command the facilitator can never settle (and at worst replays a stale
1741
+ // intent). Value.timestamp is microseconds since the Unix epoch.
1742
+ if (c.expiresAt !== undefined) {
1743
+ const nowMs = expect.nowMs ?? Date.now();
1744
+ let expiresMs;
1745
+ try {
1746
+ expiresMs = Number(BigInt(c.expiresAt) / 1000n);
1747
+ }
1748
+ catch {
1749
+ expiresMs = NaN;
1750
+ }
1751
+ if (Number.isFinite(expiresMs) && expiresMs <= nowMs) {
1752
+ throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand expiresAt is in the past ` +
1753
+ `(${new Date(expiresMs).toISOString()}) — refusing to sign`);
1754
+ }
1755
+ }
1756
+ // Cross-check the authoritative submitter: REQUIRE a non-empty act_as whose
1757
+ // only party is the sender (closes GAP A3 — an empty act_as no longer skips
1758
+ // this). Placed after the field comparison so a consistent-attacker tx
1759
+ // surfaces as a field mismatch first.
1760
+ assertActAsIsSender(decoded.actAs, expect.sender);
1761
+ // Position-aware foreign-party backstop over the WHOLE signed message (all node
1762
+ // Value payloads + node-level party metadata + Metadata.input_contracts): no
1763
+ // party other than {sender, receiver, delegate} may appear, EXCEPT the
1764
+ // expectedDso (DSO) at its known root position (1). The DSO is value-excluded
1765
+ // OUTSIDE its root position ONLY when pinned to an independently-trusted value
1766
+ // (expect.expectedDso) — so a relay-chosen, unpinned DSO can no longer be
1767
+ // aliased to the attacker and smuggled in as a consequence/metadata recipient.
1768
+ const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
1769
+ assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver, expect.delegate]), c.expectedDso, 1 /* expectedDso */, expect.expectedDso !== undefined /* trusted iff caller pinned it */);
1770
+ }
1771
+ /* ════════════════════════════════════════════════════════════════════════
1772
+ * claim path — verify-before-sign for `TransferInstruction_Accept`.
1773
+ *
1774
+ * THREAT MODEL: the relay is MALICIOUS. `claimAll` accepts INCOMING transfers
1775
+ * (funds the agent is RECEIVING). Previously it passed NO verify and NO hash
1776
+ * binding, so the agent blind-signed whatever the relay returned — a malicious
1777
+ * relay could return an OUTBOUND drain (a CreateTransferCommand / TransferFactory
1778
+ * _Transfer sending the agent's balance to an attacker) with the matching honest
1779
+ * hash, and the agent would sign it. A single Ed25519 signature authorizes the
1780
+ * WHOLE transaction, so the claim path must be gated like every value-moving
1781
+ * path.
1782
+ *
1783
+ * The decisive structural property of a legitimate claim: its SINGLE ROOT
1784
+ * exercise is `TransferInstruction_Accept`, and there is NO OTHER exercise of any
1785
+ * choice — in particular no outbound transfer/create-transfer-command leg. We
1786
+ * reuse the shared invariant (`assertSingleAllowedRootExercise` with
1787
+ * allowedChoiceId = TransferInstruction_Accept), which fails closed on a drain
1788
+ * (its root is an outbound choice ≠ the accept) and on any extra leg / hidden
1789
+ * node, and we require the authoritative submitter (act_as) to be exactly the
1790
+ * agent. We deliberately do NOT pin a foreign-party allowlist here: an accept of
1791
+ * an incoming transfer legitimately references its counterparties (the sender
1792
+ * paying the agent, the DSO, etc.) — the root-choice + act_as + hash-binding gate
1793
+ * is what makes a drain unsignable, not a recipient allowlist.
1794
+ * ════════════════════════════════════════════════════════════════════════ */
1795
+ /** The accept choice on a Splice TransferInstruction (funds-in / claim path). */
1796
+ const ACCEPT_CHOICE = "TransferInstruction_Accept";
1797
+ /**
1798
+ * Assert the relay-returned `preparedTransaction` for the claim path encodes
1799
+ * EXACTLY a single `TransferInstruction_Accept` exercise submitted by the agent —
1800
+ * and is NOT an outbound transfer/create-transfer-command drain. Throws
1801
+ * `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError` if
1802
+ * the bytes are not a decodable PreparedTransaction. Call BEFORE signing the
1803
+ * hash. Fail-closed: anything not positively proven to be an inbound accept by
1804
+ * the agent throws.
1805
+ */
1806
+ export function assertPreparedAcceptMatches(preparedTransactionB64, expect) {
1807
+ const decoded = decodePrepared(preparedTransactionB64);
1808
+ // Shared node-traversal invariant: exactly one allowed exercise & NO other
1809
+ // exercise of any choice (so a relay-injected outbound CreateTransferCommand /
1810
+ // TransferFactory_Transfer drain is rejected — its root is not the accept);
1811
+ // EVERY node recognized; EXACTLY ONE root that IS the TransferInstruction_Accept
1812
+ // exercise; no orphan/extra-leg node.
1813
+ assertSingleAllowedRootExercise(decoded, ACCEPT_CHOICE);
1814
+ // SIGNED timing metadata sanity (relay-chosen validity window).
1815
+ assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
1816
+ // Cross-check the authoritative submitter: REQUIRE a non-empty act_as whose
1817
+ // only party is the agent — the accept must be submitted under the agent's own
1818
+ // authority, never a third party's.
1819
+ assertActAsIsSender(decoded.actAs, expect.selfParty);
1820
+ }
1821
+ /** Thrown when a supplied `recomputeHash` cannot faithfully encode the bytes. */
1822
+ export class PreparedHashUnavailableError extends Error {
1823
+ constructor(message) {
1824
+ super(message);
1825
+ this.name = "PreparedHashUnavailableError";
1826
+ }
1827
+ }
1828
+ /** Constant-time base64 comparison (length-independent leak is acceptable). */
1829
+ function constantTimeEqualB64(a, b) {
1830
+ let ba;
1831
+ let bb;
1832
+ try {
1833
+ ba = Buffer.from(a, "base64");
1834
+ bb = Buffer.from(b, "base64");
1835
+ }
1836
+ catch {
1837
+ return false;
1838
+ }
1839
+ if (ba.length !== bb.length || ba.length === 0)
1840
+ return false;
1841
+ return timingSafeEqual(ba, bb);
1842
+ }
1843
+ /**
1844
+ * Bind the `hash` the relay told us to sign to the `preparedTransaction` bytes
1845
+ * we just structurally validated. Call this BEFORE signing `hash`.
1846
+ *
1847
+ * THE BINDING (why blind-signing breaks self-custody)
1848
+ * ---------------------------------------------------
1849
+ * The signed artifact is `hash`. The participant, on `execute`, recomputes the
1850
+ * V2 hash from whatever bytes reach it and accepts the signature only if it
1851
+ * matches. A compromised relay (the participant proxy) can therefore return
1852
+ * structurally-HONEST `preparedTransaction` bytes (which sail through
1853
+ * `assertPreparedTransferMatches`) paired with `hash = V2(PT_evil)` for a
1854
+ * DIFFERENT transfer, then forward `PT_evil` instead of the honest bytes to the
1855
+ * participant: V2(PT_evil) == hash, signature valid, attacker paid. Validating
1856
+ * the bytes is therefore necessary but NOT sufficient — the agent must also
1857
+ * prove the hash it signs is the hash OF THOSE validated bytes. That requires
1858
+ * recomputing the hash locally; the relay-supplied hash is untrusted input.
1859
+ *
1860
+ * This function enforces, fail-closed:
1861
+ * 1. shape: `hash` is a present, well-formed, non-empty base64 digest, and the
1862
+ * `preparedTransaction` is a decodable PreparedTransaction with exactly the
1863
+ * one transfer exercise (so we are signing a transfer we understood);
1864
+ * 2. binding: EITHER `opts.recomputeHash` is supplied and
1865
+ * `recomputeHash(prepared) === hash` (constant-time) — the real binding —
1866
+ * OR `opts.trustRelayHash === true` is explicitly set (the documented,
1867
+ * off-by-default escape hatch for human-in-the-loop / trusted-relay use).
1868
+ * If neither holds, we REFUSE to sign rather than blind-sign an unbound,
1869
+ * relay-chosen hash.
1870
+ */
1871
+ export async function assertHashBinding(preparedTransactionB64, hashB64, opts = {}) {
1872
+ if (typeof hashB64 !== "string" || hashB64.length === 0) {
1873
+ throw new PreparedTransferMismatchError("relay returned an empty hash — refusing to sign (possible tampered/compromised relay)");
1874
+ }
1875
+ let hashBytes;
1876
+ try {
1877
+ hashBytes = Buffer.from(hashB64, "base64");
1878
+ }
1879
+ catch {
1880
+ throw new PreparedTransferMismatchError("relay hash is not valid base64 — refusing to sign");
1881
+ }
1882
+ if (hashBytes.length === 0) {
1883
+ throw new PreparedTransferMismatchError("relay hash decoded to empty bytes — refusing to sign");
1884
+ }
1885
+ // Decoding here ensures the bytes we are about to sign-and-submit are a real,
1886
+ // structurally-valid PreparedTransaction (throws otherwise).
1887
+ decodePrepared(preparedTransactionB64);
1888
+ // The actual hash<->bytes binding.
1889
+ if (opts.recomputeHash) {
1890
+ let local;
1891
+ try {
1892
+ // Await covers both a sync `=> string` and the async WebCrypto-backed
1893
+ // conformant recompute (`=> Promise<string>`); a rejected promise lands
1894
+ // in catch and fails CLOSED, never falling back to the relay hash.
1895
+ local = await opts.recomputeHash(preparedTransactionB64);
1896
+ }
1897
+ catch (e) {
1898
+ // The recompute could not faithfully encode the bytes — fail CLOSED. We
1899
+ // never fall back to trusting the relay hash on a recompute failure.
1900
+ throw new PreparedTransferMismatchError(`could not recompute the prepared-transaction hash to bind it to the ` +
1901
+ `validated bytes (${e.message}) — refusing to sign`);
1902
+ }
1903
+ if (!constantTimeEqualB64(local, hashB64)) {
1904
+ throw new PreparedTransferMismatchError("relay-returned hash does NOT match the hash of the prepared-transaction " +
1905
+ "bytes we validated — refusing to sign (possible tampered/compromised " +
1906
+ "relay supplying the hash of a different transaction)");
1907
+ }
1908
+ return; // bound: the hash we sign is the hash of the bytes we validated
1909
+ }
1910
+ if (opts.trustRelayHash === true) {
1911
+ return; // explicit, documented, off-by-default escape hatch
1912
+ }
1913
+ // No way to bind the hash to the validated bytes and no explicit opt-in to
1914
+ // trust the relay → refuse. Blind-signing a relay-chosen hash is exactly the
1915
+ // self-custody break this module exists to prevent.
1916
+ throw new PreparedTransferMismatchError("cannot bind the relay-returned hash to the validated prepared-transaction " +
1917
+ "bytes: no hash recomputation is available and trustRelayHash is not set. " +
1918
+ "Refusing to blind-sign a relay-supplied hash (Canton requires recomputing " +
1919
+ "the hash when the preparing participant is not trusted). Supply " +
1920
+ "HashBindingOptions.recomputeHash with a participant-conformant V2 hash, or " +
1921
+ "explicitly set trustRelayHash:true only with human-in-the-loop approval.");
1922
+ }
1923
+ /**
1924
+ * Returns true iff `hash` is the plain SHA-256 of the `preparedTransaction`
1925
+ * bytes. NOTE: Canton V2 does NOT hash this way (see `assertHashBinding`), so
1926
+ * this is advisory only and is NEVER used to accept or reject a submission.
1927
+ * Retained for diagnostics / future schemes.
1928
+ */
1929
+ export function hashMatchesPreparedPlain(preparedTransactionB64, hashB64) {
1930
+ const bytes = Buffer.from(preparedTransactionB64, "base64");
1931
+ const digest = createHash("sha256").update(bytes).digest("base64");
1932
+ return digest === hashB64;
1933
+ }
1934
+ /* ════════════════════════════════════════════════════════════════════════
1935
+ * ONBOARDING — verify-before-sign for the external-party topology multiHash.
1936
+ *
1937
+ * THREAT MODEL (identical to the transfer paths). During onboarding the agent
1938
+ * generates its OWN Ed25519 key, asks the relay to `generateExternalParty-
1939
+ * Topology`, and signs the relay-returned `multiHash` (a combined hash over the
1940
+ * onboarding/topology transactions) with that key, then submits it to
1941
+ * `allocateExternalParty`. If it signs the multiHash BLINDLY, a malicious relay
1942
+ * can return topology transactions that onboard a DIFFERENT key/party: the agent
1943
+ * would sign and submit a topology it never authored, and persist a party it does
1944
+ * not control (key-custody compromise).
1945
+ *
1946
+ * `assertOnboardingTopologyBindsKey` proves, fail-closed, that the topology
1947
+ * TRANSACTION BYTES the agent is about to submit (the same bytes the participant
1948
+ * re-derives the multiHash from on `allocate`) bind ONLY the agent's own key:
1949
+ *
1950
+ * (A) the agent's OWN raw Ed25519 public key appears as a byte-substring in the
1951
+ * topology transactions (belt-and-suspenders; (D) is authoritative). The
1952
+ * raw 32 bytes are also a contiguous suffix of the SPKI/DER encoding, so the
1953
+ * check is robust to RAW or DER framing.
1954
+ *
1955
+ * (B) the returned `party` is `name::fingerprint` whose namespace fingerprint
1956
+ * equals the returned `publicKeyFingerprint`. An external party lives in the
1957
+ * namespace of its OWN signing key (the namespace IS the key fingerprint),
1958
+ * so the party the agent persists must be in the onboarded key's namespace.
1959
+ *
1960
+ * (C) `publicKeyFingerprint` is present and non-empty (the namespace anchor).
1961
+ *
1962
+ * (D) AUTHORITATIVE structural decode: each onboarding transaction is decoded
1963
+ * as a real Canton `TopologyTransaction` (official
1964
+ * `decodeTopologyTransaction`) and the custody-granting mappings are
1965
+ * asserted to bind EXACTLY the agent's key — PartyToKeyMapping threshold 1
1966
+ * with the agent's key as the SOLE signer (no co-holder), any
1967
+ * NamespaceDelegation targets only the agent's key over its own namespace,
1968
+ * any PartyToParticipant is single-custody. This replaces the old
1969
+ * substring-only check (which a relay could satisfy while ALSO binding an
1970
+ * extra foreign co-signer) and closes the round-5 / convergence custody-
1971
+ * hijack vectors. See `assertOnboardingTopologyDecodeBindsKey`.
1972
+ *
1973
+ * COMPLEMENTARY hash binding (in onboard.ts, NOT here): the multiHash IS now
1974
+ * recomputed locally (`recomputeTopologyMultiHash`, official
1975
+ * @canton-network/core-tx-visualizer) and compared to the relay's `hashToSign`,
1976
+ * and the fingerprint is derived locally and compared to `publicKeyFingerprint`,
1977
+ * before signing the RECOMPUTED multiHash. Structural decode proves "the bytes
1978
+ * bind only my key"; the recompute proves "I signed the hash OF those bytes" —
1979
+ * both required, exactly as on the transfer path. The recompute is conformance-
1980
+ * tested (`canton-hash.conformance.test.ts`); a wrong recompute fails honest
1981
+ * onboarding CLOSED rather than mis-binding.
1982
+ * ──────────────────────────────────────────────────────────────────────── */
1983
+ /** Thrown when relay-returned onboarding topology cannot be proven to bind the
1984
+ * agent's own key + namespace. Onboarding refuses to sign the multiHash. */
1985
+ export class OnboardingTopologyMismatchError extends Error {
1986
+ constructor(message) {
1987
+ super(message);
1988
+ this.name = "OnboardingTopologyMismatchError";
1989
+ }
1990
+ }
1991
+ /** The raw 32-byte Ed25519 public key from an SPKI/DER base64, via JWK `x`
1992
+ * (robust to DER prefix length); falls back to the trailing-32-bytes slice if
1993
+ * the key cannot be parsed as Ed25519. */
1994
+ function rawEd25519PublicKey(spkiB64) {
1995
+ const der = Buffer.from(spkiB64, "base64");
1996
+ try {
1997
+ const jwk = createPublicKey({ key: der, format: "der", type: "spki" }).export({
1998
+ format: "jwk",
1999
+ });
2000
+ if (jwk.x) {
2001
+ const raw = Buffer.from(jwk.x, "base64url");
2002
+ if (raw.length === 32)
2003
+ return raw;
2004
+ }
2005
+ }
2006
+ catch {
2007
+ /* fall through to the slice form */
2008
+ }
2009
+ return der.subarray(Math.max(0, der.length - 32));
2010
+ }
2011
+ /** The namespace (fingerprint) part of a `name::fingerprint` party id, or
2012
+ * undefined if the id is not in that form. Canton forbids consecutive colons
2013
+ * inside the name, so the LAST `::` separates name from namespace. */
2014
+ function partyNamespace(party) {
2015
+ const idx = party.lastIndexOf("::");
2016
+ if (idx <= 0)
2017
+ return undefined; // no separator, or empty name
2018
+ const ns = party.slice(idx + 2);
2019
+ return ns.length > 0 ? ns : undefined;
2020
+ }
2021
+ /**
2022
+ * Assert the relay-returned onboarding topology binds the agent's OWN key and
2023
+ * namespace. Call this BEFORE signing the onboarding `multiHash`. Fail-closed:
2024
+ * anything not positively proven throws `OnboardingTopologyMismatchError`.
2025
+ */
2026
+ export function assertOnboardingTopologyBindsKey(onboardingTransactionsB64, expect) {
2027
+ // (C) namespace anchor must be present.
2028
+ if (typeof expect.publicKeyFingerprint !== "string" ||
2029
+ expect.publicKeyFingerprint.length === 0) {
2030
+ throw new OnboardingTopologyMismatchError("relay returned an empty publicKeyFingerprint — refusing to sign the onboarding " +
2031
+ "topology (cannot anchor the party namespace to the agent's key)");
2032
+ }
2033
+ // (B) the party the agent will persist must live in the namespace of the key
2034
+ // being onboarded: party = name::fingerprint, namespace == publicKeyFingerprint.
2035
+ const ns = partyNamespace(expect.party);
2036
+ if (ns === undefined) {
2037
+ throw new OnboardingTopologyMismatchError(`relay returned a malformed party id ${JSON.stringify(expect.party)} (expected ` +
2038
+ `"name::fingerprint") — refusing to sign the onboarding topology`);
2039
+ }
2040
+ if (ns !== expect.publicKeyFingerprint) {
2041
+ throw new OnboardingTopologyMismatchError(`relay-returned party namespace ${JSON.stringify(ns)} does not equal the key fingerprint ` +
2042
+ `${JSON.stringify(expect.publicKeyFingerprint)} — refusing to sign the onboarding topology ` +
2043
+ `(party would not live in the agent key's own namespace — possible foreign-party custody hijack)`);
2044
+ }
2045
+ // (A) the agent's OWN raw public key must appear in the topology bytes
2046
+ // (belt-and-suspenders byte check; (D) below is the authoritative decode).
2047
+ if (!onboardingTransactionsB64 || onboardingTransactionsB64.length === 0) {
2048
+ throw new OnboardingTopologyMismatchError("relay returned no onboarding topology transactions — refusing to sign " +
2049
+ "(cannot prove the topology onboards the agent's own key)");
2050
+ }
2051
+ const rawKey = rawEd25519PublicKey(expect.publicKeySpkiB64);
2052
+ if (rawKey.length !== 32) {
2053
+ throw new OnboardingTopologyMismatchError("could not derive the agent's raw Ed25519 public key — refusing to sign the onboarding topology");
2054
+ }
2055
+ let keyFound = false;
2056
+ for (const txB64 of onboardingTransactionsB64) {
2057
+ let txBytes;
2058
+ try {
2059
+ txBytes = Buffer.from(txB64, "base64");
2060
+ }
2061
+ catch {
2062
+ continue;
2063
+ }
2064
+ if (txBytes.length > 0 && txBytes.includes(rawKey)) {
2065
+ keyFound = true;
2066
+ break;
2067
+ }
2068
+ }
2069
+ if (!keyFound) {
2070
+ throw new OnboardingTopologyMismatchError("the agent's own public key does not appear in the relay-returned onboarding topology " +
2071
+ "transactions — refusing to sign (the topology would onboard a DIFFERENT key; possible " +
2072
+ "tampered/compromised relay performing a key-custody hijack)");
2073
+ }
2074
+ // (D) AUTHORITATIVE structural decode. The byte-substring check (A) proves the
2075
+ // key is PRESENT somewhere; it does NOT prove the AUTHORITATIVE mappings bind
2076
+ // EXACTLY the agent's own key with no foreign co-holders. Decode each topology
2077
+ // transaction as a real Canton `TopologyTransaction` proto and assert,
2078
+ // fail-closed, that the custody-granting mappings bind only the agent's key.
2079
+ // This closes the round-5 / convergence custody-hijack vectors that a substring
2080
+ // scan misses (an extra signing key in the PartyToKeyMapping, threshold>1, a
2081
+ // foreign NamespaceDelegation target, a co-hosting foreign participant set with
2082
+ // a consortium threshold, etc.).
2083
+ assertOnboardingTopologyDecodeBindsKey(onboardingTransactionsB64, expect, rawKey);
2084
+ }
2085
+ /* ──────────────────────────────────────────────────────────────────────────
2086
+ * (D) Structural topology decode — the authoritative custody binding.
2087
+ *
2088
+ * We decode each relay-returned onboarding transaction with the OFFICIAL
2089
+ * `decodeTopologyTransaction` (@canton-network/core-tx-visualizer) and inspect
2090
+ * the typed `TopologyMapping` oneof. The onboarding bundle for an external party
2091
+ * carries (at least) a PartyToKeyMapping (the party's signing key(s)); it may
2092
+ * also carry a NamespaceDelegation (authorizing a key over the party's
2093
+ * namespace) and a PartyToParticipant (hosting). We require, fail-closed:
2094
+ *
2095
+ * PartyToKeyMapping (REQUIRED, ≥1 across the bundle, all for the agent's party):
2096
+ * - threshold === 1
2097
+ * - signingKeys is EXACTLY [the agent's own key] — one key, equal to the
2098
+ * agent's, and NO additional key-holders (an extra co-signer would let the
2099
+ * relay co-authorize spends).
2100
+ *
2101
+ * NamespaceDelegation (OPTIONAL; if present, every one must):
2102
+ * - namespace === the agent's key fingerprint (the party's own namespace)
2103
+ * - targetKey === the agent's own key (no foreign delegate gets namespace
2104
+ * authority).
2105
+ *
2106
+ * PartyToParticipant (OPTIONAL; if present, every one must):
2107
+ * - party === the agent's party
2108
+ * - threshold === 1 (NOT a consortium party — threshold>1 means multiple
2109
+ * participants must co-act, a custody-sharing definition)
2110
+ * - participants is non-empty (hosting is sane).
2111
+ *
2112
+ * Any mapping that references a key/namespace/party that is NOT the agent's, or
2113
+ * any threshold>1, or any extra key-holder, is rejected. Mapping types we do not
2114
+ * expect in an external-party onboarding bundle (owner-to-key, decentralized
2115
+ * namespace, synchronizer state, …) are also rejected fail-closed — an honest
2116
+ * relay does not include them, and we refuse to sign a bundle we cannot fully
2117
+ * account for.
2118
+ * ──────────────────────────────────────────────────────────────────────── */
2119
+ /** Extract the raw key bytes carried by a decoded `SigningPublicKey`, normalized
2120
+ * to the bare 32-byte Ed25519 point. Canton may carry the key RAW (32 bytes) or
2121
+ * DER-wrapped (SPKI); the raw 32-byte point is a contiguous suffix of the SPKI
2122
+ * encoding, so taking the trailing 32 bytes normalizes both. Returns undefined
2123
+ * if there are fewer than 32 bytes (malformed). */
2124
+ function rawPointFromSigningKey(pk) {
2125
+ if (!pk || !pk.publicKey || pk.publicKey.length < 32)
2126
+ return undefined;
2127
+ const buf = Buffer.from(pk.publicKey);
2128
+ // If it already IS 32 bytes, this is the point; otherwise (DER) the point is
2129
+ // the trailing 32 bytes (the SPKI BIT STRING payload sits at the end).
2130
+ return buf.subarray(buf.length - 32);
2131
+ }
2132
+ function assertOnboardingTopologyDecodeBindsKey(onboardingTransactionsB64, expect, agentRawKey) {
2133
+ /** True iff a decoded signing key is EXACTLY the agent's own Ed25519 point. */
2134
+ const isAgentKey = (pk) => {
2135
+ const point = rawPointFromSigningKey(pk);
2136
+ return point !== undefined && point.length === 32 && timingSafeEqual(point, agentRawKey);
2137
+ };
2138
+ let sawPartyToKey = false;
2139
+ for (const txB64 of onboardingTransactionsB64) {
2140
+ let tx;
2141
+ try {
2142
+ tx = decodeTopologyTransaction(txB64);
2143
+ }
2144
+ catch (e) {
2145
+ throw new OnboardingTopologyMismatchError(`could not decode a relay-returned onboarding transaction as a Canton ` +
2146
+ `TopologyTransaction (${e.message}) — refusing to sign ` +
2147
+ `(cannot structurally verify the topology binds the agent's own key)`);
2148
+ }
2149
+ const mapping = tx.mapping?.mapping;
2150
+ if (!mapping || mapping.oneofKind === undefined) {
2151
+ throw new OnboardingTopologyMismatchError("a relay-returned onboarding transaction has no topology mapping — refusing " +
2152
+ "to sign (cannot account for an empty/unknown mapping in the bundle)");
2153
+ }
2154
+ switch (mapping.oneofKind) {
2155
+ case "partyToKeyMapping": {
2156
+ const m = mapping.partyToKeyMapping;
2157
+ sawPartyToKey = true;
2158
+ // The mapping must be for the agent's OWN party.
2159
+ if (m.party !== expect.party) {
2160
+ throw new OnboardingTopologyMismatchError(`onboarding PartyToKeyMapping binds party ${JSON.stringify(m.party)} but the ` +
2161
+ `agent's party is ${JSON.stringify(expect.party)} — refusing to sign ` +
2162
+ `(topology would key a DIFFERENT party — possible custody hijack)`);
2163
+ }
2164
+ // EXACTLY threshold 1.
2165
+ if (m.threshold !== 1) {
2166
+ throw new OnboardingTopologyMismatchError(`onboarding PartyToKeyMapping has threshold ${m.threshold} (expected 1) — ` +
2167
+ `refusing to sign (a threshold≠1 key mapping is a custody-sharing definition)`);
2168
+ }
2169
+ // EXACTLY one signing key, and it is the agent's own — NO co-holders.
2170
+ if (m.signingKeys.length !== 1) {
2171
+ throw new OnboardingTopologyMismatchError(`onboarding PartyToKeyMapping carries ${m.signingKeys.length} signing keys ` +
2172
+ `(expected exactly 1 — the agent's own) — refusing to sign (an extra ` +
2173
+ `key-holder would let the relay co-authorize the agent's spends)`);
2174
+ }
2175
+ if (!isAgentKey(m.signingKeys[0])) {
2176
+ throw new OnboardingTopologyMismatchError("onboarding PartyToKeyMapping's signing key is NOT the agent's own key — " +
2177
+ "refusing to sign (the topology would onboard a foreign key; possible " +
2178
+ "tampered/compromised relay performing a key-custody hijack)");
2179
+ }
2180
+ break;
2181
+ }
2182
+ case "namespaceDelegation": {
2183
+ const m = mapping.namespaceDelegation;
2184
+ // If present, it must authorize ONLY the agent's own key over the agent's
2185
+ // own namespace (the key fingerprint). A foreign target/namespace would
2186
+ // grant namespace authority outside the agent's control.
2187
+ if (m.namespace !== expect.publicKeyFingerprint) {
2188
+ throw new OnboardingTopologyMismatchError(`onboarding NamespaceDelegation is for namespace ${JSON.stringify(m.namespace)} ` +
2189
+ `but the agent's namespace is ${JSON.stringify(expect.publicKeyFingerprint)} — ` +
2190
+ `refusing to sign (foreign-namespace delegation — possible custody hijack)`);
2191
+ }
2192
+ if (!isAgentKey(m.targetKey)) {
2193
+ throw new OnboardingTopologyMismatchError("onboarding NamespaceDelegation's target key is NOT the agent's own key — " +
2194
+ "refusing to sign (a foreign key would gain authority over the agent's " +
2195
+ "namespace — possible custody hijack)");
2196
+ }
2197
+ break;
2198
+ }
2199
+ case "partyToParticipant": {
2200
+ const m = mapping.partyToParticipant;
2201
+ if (m.party !== expect.party) {
2202
+ throw new OnboardingTopologyMismatchError(`onboarding PartyToParticipant hosts party ${JSON.stringify(m.party)} but the ` +
2203
+ `agent's party is ${JSON.stringify(expect.party)} — refusing to sign`);
2204
+ }
2205
+ // threshold>1 ⇒ consortium party (multiple participants must co-act) — a
2206
+ // custody-sharing definition the agent must never sign for its own party.
2207
+ if (m.threshold > 1) {
2208
+ throw new OnboardingTopologyMismatchError(`onboarding PartyToParticipant has threshold ${m.threshold} (>1 = consortium ` +
2209
+ `party) — refusing to sign (the agent's party must be single-custody)`);
2210
+ }
2211
+ if (!m.participants || m.participants.length === 0) {
2212
+ throw new OnboardingTopologyMismatchError("onboarding PartyToParticipant lists no hosting participants — refusing to " +
2213
+ "sign (the party would be unhosted/sane-check failed)");
2214
+ }
2215
+ break;
2216
+ }
2217
+ default: {
2218
+ // Any other mapping type is unexpected in an external-party onboarding
2219
+ // bundle. Fail closed rather than sign a bundle we cannot account for.
2220
+ throw new OnboardingTopologyMismatchError(`relay-returned onboarding bundle contains an unexpected topology mapping ` +
2221
+ `(${mapping.oneofKind}) — refusing to sign (an honest external-party ` +
2222
+ `onboarding carries only PartyToKeyMapping / NamespaceDelegation / ` +
2223
+ `PartyToParticipant; an extra mapping could grant foreign authority)`);
2224
+ }
2225
+ }
2226
+ }
2227
+ // The custody-granting mapping (PartyToKeyMapping) is REQUIRED: without it the
2228
+ // bundle does not actually bind the agent's key to its party.
2229
+ if (!sawPartyToKey) {
2230
+ throw new OnboardingTopologyMismatchError("relay-returned onboarding bundle has NO PartyToKeyMapping — refusing to sign " +
2231
+ "(the topology does not bind the agent's signing key to its party; cannot " +
2232
+ "establish self-custody)");
2233
+ }
2234
+ }
2235
+ //# sourceMappingURL=verify-prepared.js.map