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