@ftptech/canton-agent-wallet 0.1.20 → 0.2.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.
@@ -535,31 +535,6 @@ function leafOf(value) {
535
535
  return { kind: "timestamp", value: timestamp.toString() };
536
536
  return undefined;
537
537
  }
538
- /**
539
- * Read an `Optional Party` leaf out of a `Value`, unwrapping exactly one `Some`.
540
- *
541
- * The Splice `ExternalPartyAmuletRules_CreateTransferCommand` declares its
542
- * `expectedDso` field as `Optional Party`, so the participant encodes the present
543
- * value as `Value.optional { value: Value.party }` — NOT a bare `Value.party`.
544
- * The scalar `leafOf` only reads bare leaves, so it returns undefined for the
545
- * wrapped party and the matcher then wrongly sees `expectedDso = undefined`,
546
- * rejecting EVERY honest v1 payment. We unwrap one `Some` level here (the same
547
- * `Value.optional → Optional.value` shape `collectPartyLeaves` descends) and read
548
- * the inner party with the SAME strict leaf reader (so a duplicate/decoy inner
549
- * member still fails closed). Returns undefined for `None` or a non-party inner;
550
- * the caller pins it by exact equality to the trusted DSO, so a forged inner
551
- * party surfaces as a field mismatch. A bare (non-optional) Party is tolerated
552
- * defensively in case a future package flattens the field.
553
- */
554
- function optionalPartyLeafOf(value) {
555
- const opt = lenFieldUnique(decodeMessage(value), V_OPTIONAL, "Value.optional");
556
- if (opt === undefined)
557
- return leafOf(value); // defensive: bare Party shape
558
- const inner = lenFieldUnique(decodeMessage(opt), OPTIONAL_VALUE, "Optional.value");
559
- if (inner === undefined)
560
- return undefined; // None
561
- return leafOf(inner);
562
- }
563
538
  /** Decode a protobuf SINT64 zigzag varint to its signed BigInt value:
564
539
  * zigzag(w) = (w >> 1) ^ -(w & 1). Daml-LF serializes `Value.int64` as sint64,
565
540
  * so a wire varint of 0 is 0, 1 is -1, 2 is 1, 3 is -2, 4 is 2, … This is what
@@ -1873,214 +1848,6 @@ export function assertPreparedTransferMatches(preparedTransactionB64, expect) {
1873
1848
  const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
1874
1849
  assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver]), t.instrumentAdmin, 2 /* expectedAdmin + instrumentId.admin */, expect.instrumentAdmin !== undefined /* trusted iff caller pinned it */);
1875
1850
  }
1876
- /* ════════════════════════════════════════════════════════════════════════
1877
- * v1 (external-party-amulet-rules) — verify-before-sign for the
1878
- * `ExternalPartyAmuletRules_CreateTransferCommand` exercise.
1879
- *
1880
- * THREAT MODEL — identical to the cip56 arm above, for the v1 path. The agent
1881
- * asks the relay to PREPARE an `ExternalPartyAmuletRules_CreateTransferCommand`
1882
- * (it creates a `TransferCommand` the facilitator later settles). A compromised
1883
- * relay could prepare a DIFFERENT command — swap the receiver, inflate the
1884
- * amount, redirect the delegate to an attacker who can then steer settlement,
1885
- * or change the sender — and hand back its hash. We decode the prepared bytes
1886
- * with the SAME structural rigor (schema-pinned field numbers, fail-closed on
1887
- * duplicate/multi-member oneofs, parties matched BY TYPE not text) and assert
1888
- * each money-critical field equals CALLER INTENT at its structural position.
1889
- *
1890
- * choiceArgument :: Record {
1891
- * sender : Party, -- MUST == the agent's own party
1892
- * receiver : Party, -- MUST == merchant payTo from the 402
1893
- * delegate : Party, -- MUST == facilitatorParty from the 402 extra
1894
- * amount : Numeric, -- MUST == the required amount
1895
- * expiresAt : Time, -- sanity-checked (present, plausible)
1896
- * nonce : Int, -- sanity-checked (>= 0, matches expected)
1897
- * description : Optional Text, -- not money-critical (facilitator re-validates)
1898
- * expectedDso : Party -- relay-supplied; NOT trusted as a recipient
1899
- * }
1900
- *
1901
- * We extract sender/receiver/delegate by label, else by Daml declaration order
1902
- * (parties[0]/[1]/[2]); amount = the single Numeric leaf; nonce = the single
1903
- * Int64 leaf. Every field read uses the unique accessors so a duplicate/decoy
1904
- * occurrence fails closed rather than diverging from the participant's
1905
- * last-wins parse. The foreign-party backstop allows ONLY {sender, receiver,
1906
- * delegate} plus the expectedDso AT its own position — any other party (an extra
1907
- * leg, a smuggled GenMap recipient) is rejected. expectedDso is relay-supplied,
1908
- * so it is allowed positionally but NEVER used to widen the recipient allowlist.
1909
- * ════════════════════════════════════════════════════════════════════════ */
1910
- /** The create choice on ExternalPartyAmuletRules (v1 / facilitator-pays-gas). */
1911
- const CREATE_TRANSFER_COMMAND_CHOICE = "ExternalPartyAmuletRules_CreateTransferCommand";
1912
- /**
1913
- * Extract the v1 create-command fields from its (flat) choice-argument record,
1914
- * BY TYPE at its DAML DECLARATION-ORDER POSITION (what the participant binds),
1915
- * NOT by label — and fail closed on any label/position divergence (the
1916
- * amount-inflation / receiver-swap vector). Declaration order:
1917
- * [0] sender:Party [1] receiver:Party [2] delegate:Party [3] amount:Numeric
1918
- * [4] expiresAt:Time [5] nonce:Int [6] description:Optional Text [7] expectedDso:Party
1919
- * Honest encodings — fully labelled in order OR label-free (normalized) — both
1920
- * pass; a record whose labels disagree with declaration order (a decoy field
1921
- * re-using a money-critical label, or a mislabeled value at a money position) is
1922
- * rejected. Fails closed if a money-critical field is the wrong type / missing.
1923
- */
1924
- export function extractCreateTransferCommand(chosenValue) {
1925
- const entries = recordEntries(chosenValue);
1926
- // ── positional reads at Daml declaration order, with label/position guard ──
1927
- const senderE = entryByDeclOrder(entries, 0, "sender");
1928
- const receiverE = entryByDeclOrder(entries, 1, "receiver");
1929
- const delegateE = entryByDeclOrder(entries, 2, "delegate");
1930
- const amountE = entryByDeclOrder(entries, 3, "amount");
1931
- const expiresAtE = entryByDeclOrder(entries, 4, "expiresAt");
1932
- const nonceE = entryByDeclOrder(entries, 5, "nonce");
1933
- // position 6 = description:Optional Text — not money-critical; skip.
1934
- const expectedDsoE = entryByDeclOrder(entries, 7, "expectedDso");
1935
- const sender = senderE ? leafOf(senderE.value) : undefined;
1936
- const receiver = receiverE ? leafOf(receiverE.value) : undefined;
1937
- const delegate = delegateE ? leafOf(delegateE.value) : undefined;
1938
- const amount = amountE ? leafOf(amountE.value) : undefined;
1939
- const nonce = nonceE ? leafOf(nonceE.value) : undefined;
1940
- const expiresAt = expiresAtE ? leafOf(expiresAtE.value) : undefined;
1941
- // expectedDso is relay-supplied; read at its own position for the backstop
1942
- // exclusion only, NEVER as a recipient allowlist entry. It is `Optional Party`
1943
- // in the Splice choice (Value.optional → party), so unwrap one Some level —
1944
- // the bare leafOf would read undefined and reject every honest v1 payment.
1945
- const expectedDso = expectedDsoE
1946
- ? optionalPartyLeafOf(expectedDsoE.value)
1947
- : undefined;
1948
- if (!sender || sender.kind !== "party") {
1949
- throw new PreparedDecodeError("CreateTransferCommand.sender is not a party");
1950
- }
1951
- if (!receiver || receiver.kind !== "party") {
1952
- throw new PreparedDecodeError("CreateTransferCommand.receiver is not a party");
1953
- }
1954
- if (!delegate || delegate.kind !== "party") {
1955
- throw new PreparedDecodeError("CreateTransferCommand.delegate is not a party");
1956
- }
1957
- if (!amount || amount.kind !== "numeric") {
1958
- throw new PreparedDecodeError("CreateTransferCommand.amount is not numeric");
1959
- }
1960
- if (!nonce || nonce.kind !== "int64") {
1961
- throw new PreparedDecodeError("CreateTransferCommand.nonce is not an int");
1962
- }
1963
- return {
1964
- sender: sender.value,
1965
- receiver: receiver.value,
1966
- delegate: delegate.value,
1967
- amount: amount.value,
1968
- nonce: nonce.value,
1969
- ...(expiresAt && expiresAt.kind === "timestamp"
1970
- ? { expiresAt: expiresAt.value }
1971
- : {}),
1972
- ...(expectedDso && expectedDso.kind === "party"
1973
- ? { expectedDso: expectedDso.value }
1974
- : {}),
1975
- };
1976
- }
1977
- /**
1978
- * Assert the relay-returned `preparedTransaction` encodes EXACTLY the v1
1979
- * `ExternalPartyAmuletRules_CreateTransferCommand` the agent intended. Throws
1980
- * `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError` if
1981
- * the bytes are not a decodable PreparedTransaction carrying a single
1982
- * create-command exercise. Call BEFORE signing the hash. Fail-closed: anything
1983
- * not positively proven to match the intent throws.
1984
- */
1985
- export function assertPreparedCreateTransferCommandMatches(preparedTransactionB64, expect) {
1986
- const decoded = decodePrepared(preparedTransactionB64);
1987
- // Node-traversal invariant (shared with the cip56 arm): exactly one allowed
1988
- // exercise & no other; EVERY node recognized (no node hidden under an unknown
1989
- // node version/type — closes the "sibling Create" and "node-version 1001"
1990
- // bypasses); EXACTLY ONE root that IS the single allowed CreateTransferCommand
1991
- // exercise; no orphan/extra-leg node. Returns the validated root exercise.
1992
- const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, CREATE_TRANSFER_COMMAND_CHOICE);
1993
- const c = extractCreateTransferCommand(ex.chosenValue);
1994
- const mismatches = [];
1995
- if (c.sender !== expect.sender) {
1996
- mismatches.push(`sender (got ${JSON.stringify(c.sender)}, intended ${JSON.stringify(expect.sender)})`);
1997
- }
1998
- if (c.receiver !== expect.receiver) {
1999
- mismatches.push(`receiver (got ${JSON.stringify(c.receiver)}, intended ${JSON.stringify(expect.receiver)})`);
2000
- }
2001
- if (c.delegate !== expect.delegate) {
2002
- mismatches.push(`delegate (got ${JSON.stringify(c.delegate)}, intended ${JSON.stringify(expect.delegate)})`);
2003
- }
2004
- if (canonicalAmount(c.amount) !== canonicalAmount(expect.amount)) {
2005
- mismatches.push(`amount (got ${JSON.stringify(c.amount)}, intended ${JSON.stringify(expect.amount)})`);
2006
- }
2007
- if (expect.nonce !== undefined && c.nonce !== expect.nonce) {
2008
- mismatches.push(`nonce (got ${JSON.stringify(c.nonce)}, intended ${JSON.stringify(expect.nonce)})`);
2009
- }
2010
- // Pin the DSO only if the caller supplied an independently-trusted value.
2011
- if (expect.expectedDso !== undefined && c.expectedDso !== expect.expectedDso) {
2012
- mismatches.push(`expectedDso (got ${JSON.stringify(c.expectedDso)}, intended ${JSON.stringify(expect.expectedDso)})`);
2013
- }
2014
- if (mismatches.length > 0) {
2015
- throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand does not match intent: ` +
2016
- `${mismatches.join("; ")} — refusing to sign ` +
2017
- `(possible tampered/compromised relay redirecting funds)`);
2018
- }
2019
- // The DSO must never be aliased to a money role — otherwise a relay could set
2020
- // the (unpinned) expectedDso to the receiver/sender/delegate and have the
2021
- // backstop exempt a money party. Reject up front.
2022
- if (c.expectedDso !== undefined &&
2023
- (c.expectedDso === expect.sender ||
2024
- c.expectedDso === expect.receiver ||
2025
- c.expectedDso === expect.delegate)) {
2026
- throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand expectedDso ${JSON.stringify(c.expectedDso)} equals a ` +
2027
- `command party (sender/receiver/delegate) — refusing to sign`);
2028
- }
2029
- // Pin WHICH template + WHICH contract the choice runs against
2030
- // (template/contract-confusion + resolve→prepare TOCTOU) and the SIGNED
2031
- // synchronizer + timing metadata (relay-chosen domain / validity window).
2032
- // No-ops unless the caller supplies the corresponding intent.
2033
- assertTemplateMatches(ex, expect.templateQualifiedName);
2034
- assertContractIdMatches(ex, expect.expectedContractId);
2035
- assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
2036
- assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2037
- // nonce sanity: a negative nonce is never legitimate (Daml Int can be
2038
- // negative on the wire, but a TransferCommand nonce is a monotonic counter).
2039
- let nonceBig;
2040
- try {
2041
- nonceBig = BigInt(c.nonce);
2042
- }
2043
- catch {
2044
- throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand has a non-integer nonce ` +
2045
- `${JSON.stringify(c.nonce)} — refusing to sign`);
2046
- }
2047
- if (nonceBig < 0n) {
2048
- throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand has a negative nonce ` +
2049
- `${JSON.stringify(c.nonce)} — refusing to sign`);
2050
- }
2051
- // expiresAt sanity (best-effort): when present as a timestamp leaf, reject a
2052
- // value already in the past — a relay-set expiry that has lapsed would create
2053
- // a command the facilitator can never settle (and at worst replays a stale
2054
- // intent). Value.timestamp is microseconds since the Unix epoch.
2055
- if (c.expiresAt !== undefined) {
2056
- const nowMs = expect.nowMs ?? Date.now();
2057
- let expiresMs;
2058
- try {
2059
- expiresMs = Number(BigInt(c.expiresAt) / 1000n);
2060
- }
2061
- catch {
2062
- expiresMs = NaN;
2063
- }
2064
- if (Number.isFinite(expiresMs) && expiresMs <= nowMs) {
2065
- throw new PreparedTransferMismatchError(`relay-prepared CreateTransferCommand expiresAt is in the past ` +
2066
- `(${new Date(expiresMs).toISOString()}) — refusing to sign`);
2067
- }
2068
- }
2069
- // Cross-check the authoritative submitter: REQUIRE a non-empty act_as whose
2070
- // only party is the sender (closes GAP A3 — an empty act_as no longer skips
2071
- // this). Placed after the field comparison so a consistent-attacker tx
2072
- // surfaces as a field mismatch first.
2073
- assertActAsIsSender(decoded.actAs, expect.sender);
2074
- // Position-aware foreign-party backstop over the WHOLE signed message (all node
2075
- // Value payloads + node-level party metadata + Metadata.input_contracts): no
2076
- // party other than {sender, receiver, delegate} may appear, EXCEPT the
2077
- // expectedDso (DSO) at its known root position (1). The DSO is value-excluded
2078
- // OUTSIDE its root position ONLY when pinned to an independently-trusted value
2079
- // (expect.expectedDso) — so a relay-chosen, unpinned DSO can no longer be
2080
- // aliased to the attacker and smuggled in as a consequence/metadata recipient.
2081
- const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2082
- assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver, expect.delegate]), c.expectedDso, 1 /* expectedDso */, expect.expectedDso !== undefined /* trusted iff caller pinned it */);
2083
- }
2084
1851
  /* ════════════════════════════════════════════════════════════════════════
2085
1852
  * claim path — verify-before-sign for `TransferInstruction_Accept`.
2086
1853
  *
@@ -2558,261 +2325,6 @@ export function assertPreparedAllocationWithdrawMatches(preparedTransactionB64,
2558
2325
  allowedWithdraw.add(expect.receiver);
2559
2326
  assertNoForeignParties(leaves, allowedWithdraw, expect.instrumentAdmin /* DSO: AmuletRules signatory in the expire-lock ctx */, expect.instrumentAdmin !== undefined ? 4 : 0 /* AmuletRules + round + unlock refs */, expect.instrumentAdmin !== undefined /* trusted iff caller pinned it */);
2560
2327
  }
2561
- /* ════════════════════════════════════════════════════════════════════════
2562
- * x402-escrow "Design A" ACCEPT — verify-before-sign for
2563
- * `X402EscrowOffer_Accept` (the sender captures its one-time authorization).
2564
- *
2565
- * THREAT MODEL. The facilitator created an `X402EscrowOffer` (signatory
2566
- * facilitator, observer sender) and the SENDER — an external party whose key the
2567
- * agent-wallet holds — must exercise `X402EscrowOffer_Accept` to durably capture
2568
- * its authorization. The choice's sole controller is the sender; ACCEPTING MOVES
2569
- * NO FUNDS — it only archives the offer and creates the `X402Escrow` whose
2570
- * signatories are {facilitator, sender}. (Source of truth: daml/x402-escrow/daml/
2571
- * X402Escrow.daml — template `X402EscrowOffer`, choice `X402EscrowOffer_Accept`,
2572
- * no choice args, `create X402Escrow with {facilitator, sender, merchant, amount,
2573
- * instrumentAdmin}`; X402Escrow `signatory facilitator, sender`.)
2574
- *
2575
- * Even though the accept moves no funds, the agent must NOT blind-sign a
2576
- * relay-prepared `X402EscrowOffer_Accept`: a compromised relay could instead
2577
- * prepare (a) a DIFFERENT, value-MOVING choice (an `Allocation_ExecuteTransfer` /
2578
- * `TransferFactory_Transfer` / `ExternalPartyAmuletRules_CreateTransferCommand` /
2579
- * `Allocation_Withdraw` drain), (b) an accept of a DIFFERENT offer contract than
2580
- * the one the caller intended, (c) an extra/sibling outbound transfer leg as a
2581
- * consequence (an injected Amulet/Holding create to a foreign party), or (d) the
2582
- * same choice submitted under a foreign party's authority. We apply the SAME
2583
- * fail-closed structural gate the withdraw/accept(claim) arms use — withdraw is
2584
- * the closest analog (own-party-controlled, structural, no money-critical leaf in
2585
- * the chosen value) — PLUS a REQUIRED offer contract-id pin (closing the
2586
- * resolve→prepare TOCTOU) and a position-aware foreign-party backstop whose
2587
- * ONLY allowed parties are the agent (sender) and the caller-pinned facilitator
2588
- * (the two `X402Escrow` signatories), optionally extended with the caller-pinned
2589
- * merchant + instrumentAdmin (recorded as reference fields on the created
2590
- * `X402Escrow`). ANY other party anywhere — a relay-substituted facilitator that
2591
- * could later settle the escrow to itself, an injected outbound transfer leg, a
2592
- * foreign submitter leaf — is a tamper signal and is rejected.
2593
- *
2594
- * WHAT AN ATTACKER-TAMPERED ACCEPT TRIPS:
2595
- * - choice swapped to a value-moving drain → root-choice pin (1)
2596
- * - second/sibling outbound leg or extra root → single-root + no-orphan (1)
2597
- * - accept of a different offer cid → contract-id pin (2)
2598
- * - act_as a foreign party (or self+foreign) → act_as == sender pin (4)
2599
- * - relay-substituted facilitator / smuggled → foreign-party backstop (5)
2600
- * recipient party in the created escrow / a
2601
- * consequence Amulet|Holding create
2602
- * Anything not positively proven to be a single self-submitted accept of the
2603
- * caller's own offer, creating an escrow only among the caller-intended parties,
2604
- * throws. Fail-closed in every case.
2605
- * ════════════════════════════════════════════════════════════════════════ */
2606
- /** The accept choice on an `X402EscrowOffer` (Design A escrow setup). */
2607
- const ESCROW_ACCEPT_CHOICE = "X402EscrowOffer_Accept";
2608
- /** Choices the honest `X402EscrowOffer_Accept` consequence subtree carries: it
2609
- * archives the consumed `X402EscrowOffer` and creates the `X402Escrow` (a Create
2610
- * node, NOT an exercise). The only consequence EXERCISE is the Archive of the
2611
- * offer. Kept conservative (Archive only) like the strict withdraw/allocate
2612
- * paths — a value-MOVING choice is NEVER whitelisted here, so an injected
2613
- * outbound drain (Allocation_ExecuteTransfer / TransferFactory_Transfer /
2614
- * CreateTransferCommand) as a consequence is refused. Widen ONLY against a
2615
- * confirmed live consequence subtree, never speculatively; the foreign-party
2616
- * backstop is the real guard against a smuggled outbound leg regardless. */
2617
- const ESCROW_ACCEPT_CONSEQUENCE_CHOICES = ["Archive"];
2618
- /** The created template's `module:entity` qualified name for the `X402Escrow`
2619
- * contract the accept's consequence Create produces (package-id dropped — it
2620
- * changes across upgrades). Used to positively identify the escrow Create node
2621
- * among the consequences when pinning its recorded `amount`. */
2622
- const X402_ESCROW_CREATED_QUALIFIED_NAME = "X402Escrow:X402Escrow";
2623
- /**
2624
- * Extract the `amount` recorded on a created `X402Escrow` from its `Create.argument`
2625
- * record, BY ITS DAML DECLARATION-ORDER POSITION (with the same label/position
2626
- * divergence guard the money fields use).
2627
- *
2628
- * X402Escrow create-args (X402Escrow.daml, Daml declaration order):
2629
- * [0] facilitator : Party
2630
- * [1] sender : Party
2631
- * [2] merchant : Party
2632
- * [3] amount : Decimal ← the load-bearing settle term
2633
- * [4] instrumentAdmin : Party
2634
- *
2635
- * WHY THIS MATTERS (the amount-swap bypass the party backstop cannot see). The
2636
- * accept choice has NO arguments; the escrow's terms live in this CONSEQUENCE
2637
- * Create. The foreign-party backstop only inspects PARTY leaves, so a swapped
2638
- * `amount` (a Numeric) is INVISIBLE to it. Yet the escrow's `amount` is exactly
2639
- * what the facilitator's later `X402Escrow_Settle` pins the live allocation
2640
- * against (`assertMsg "allocation amount mismatch" (leg.amount == amount)` in
2641
- * X402Escrow.daml). A malicious/buggy facilitator that authored the offer with an
2642
- * inflated `amount` (sender shown a small price) would, on a blind-signed accept,
2643
- * obtain the sender's durable authorization for an escrow that settles the larger
2644
- * amount. So when the caller pins `amount`, the wallet MUST read this Numeric at
2645
- * its real position and require exact (canonical) equality — fail closed.
2646
- *
2647
- * Returns the amount as a decimal string. Throws `PreparedDecodeError` if the
2648
- * argument is not the escrow create record shape (position 3 not a Numeric, or a
2649
- * label/position divergence) — a tamper signal in itself.
2650
- */
2651
- function extractEscrowCreateAmount(argument) {
2652
- const entries = recordEntries(argument);
2653
- const amountE = entryByDeclOrder(entries, 3, "amount");
2654
- const amount = amountE ? leafOf(amountE.value) : undefined;
2655
- if (!amount || amount.kind !== "numeric") {
2656
- throw new PreparedDecodeError("X402Escrow.amount is not numeric");
2657
- }
2658
- return amount.value;
2659
- }
2660
- /**
2661
- * Read the {facilitator, sender} parties of an `X402Escrow` create-args record at
2662
- * their declaration positions ([0] facilitator, [1] sender), for self-anchored
2663
- * identification of the escrow Create among consequence creates when the create
2664
- * carries no decodable template id. Returns undefined if the record is not the
2665
- * escrow shape at those positions (so a non-escrow consequence create is simply
2666
- * skipped, not mis-pinned).
2667
- */
2668
- function readEscrowCreatePrincipals(argument) {
2669
- let entries;
2670
- try {
2671
- entries = recordEntries(argument);
2672
- }
2673
- catch {
2674
- return undefined;
2675
- }
2676
- let facilitator;
2677
- let sender;
2678
- try {
2679
- const facE = entryByDeclOrder(entries, 0, "facilitator");
2680
- const sndE = entryByDeclOrder(entries, 1, "sender");
2681
- facilitator = facE ? leafOf(facE.value) : undefined;
2682
- sender = sndE ? leafOf(sndE.value) : undefined;
2683
- }
2684
- catch {
2685
- return undefined;
2686
- }
2687
- if (!facilitator || facilitator.kind !== "party")
2688
- return undefined;
2689
- if (!sender || sender.kind !== "party")
2690
- return undefined;
2691
- return { facilitator: facilitator.value, sender: sender.value };
2692
- }
2693
- /**
2694
- * Pin the `amount` recorded on the created `X402Escrow` to caller intent, when the
2695
- * caller supplies one. Locates the single escrow Create node among the (already
2696
- * reachability-checked) consequence nodes — by its created-template qualified name
2697
- * when present, else self-anchored on the create's {facilitator, sender} principals
2698
- * matching the caller-pinned facilitator + selfParty — then requires its Numeric
2699
- * `amount` to canonically equal `expect.amount`. Fail closed: if the caller pins an
2700
- * amount and NO matching escrow create is found (or its amount differs / is not a
2701
- * Numeric), refuse to sign. No pin ⇒ unchanged behaviour.
2702
- */
2703
- function assertEscrowCreateAmountMatches(decoded, expectedAmount, facilitator, selfParty) {
2704
- if (expectedAmount === undefined)
2705
- return; // caller did not pin — unchanged behaviour
2706
- // Candidate escrow Create nodes: prefer an exact created-template match; fall
2707
- // back to self-anchored principals (facilitator + sender) when the create
2708
- // carries no decodable template (e.g. a normalized wire). Both anchors are
2709
- // CALLER INTENT / already-trusted — never a relay-widened allowlist.
2710
- const escrowCreates = decoded.nodes.filter((n) => {
2711
- if (n.kind !== "create" || n.create === undefined || n.create.argument === undefined) {
2712
- return false;
2713
- }
2714
- if (n.create.templateQualifiedName === X402_ESCROW_CREATED_QUALIFIED_NAME)
2715
- return true;
2716
- if (n.create.templateQualifiedName !== undefined)
2717
- return false; // a DIFFERENT named template
2718
- const principals = readEscrowCreatePrincipals(n.create.argument);
2719
- return (principals !== undefined &&
2720
- principals.facilitator === facilitator &&
2721
- principals.sender === selfParty);
2722
- });
2723
- if (escrowCreates.length === 0) {
2724
- throw new PreparedTransferMismatchError(`relay-prepared escrow-accept does not create an X402Escrow with the caller's ` +
2725
- `{facilitator, sender} — refusing to sign (cannot verify the escrow's recorded amount)`);
2726
- }
2727
- if (escrowCreates.length > 1) {
2728
- throw new PreparedTransferMismatchError(`relay-prepared escrow-accept creates ${escrowCreates.length} X402Escrow contracts — ` +
2729
- `expected exactly one — refusing to sign`);
2730
- }
2731
- const argument = escrowCreates[0].create?.argument;
2732
- const onLedger = extractEscrowCreateAmount(argument);
2733
- if (canonicalAmount(onLedger) !== canonicalAmount(expectedAmount)) {
2734
- throw new PreparedTransferMismatchError(`relay-prepared escrow records amount ${JSON.stringify(onLedger)} — expected ` +
2735
- `${JSON.stringify(expectedAmount)} — refusing to sign ` +
2736
- `(a swapped escrow amount settles a different value than the sender intends)`);
2737
- }
2738
- }
2739
- /**
2740
- * Assert the relay-returned `preparedTransaction` for the escrow-accept path
2741
- * encodes EXACTLY a single `X402EscrowOffer_Accept` exercise, on the caller's own
2742
- * offer cid, submitted by the agent (sender) — and is NOT a different
2743
- * (value-moving) choice, an accept of a different contract, an outbound drain, or
2744
- * a foreign-party submission, and creates an `X402Escrow` only among the
2745
- * caller-intended parties. Throws `PreparedTransferMismatchError` on any mismatch
2746
- * and `PreparedDecodeError` if the bytes are not a decodable PreparedTransaction.
2747
- * Call BEFORE signing the hash. Fail-closed: anything not positively proven to be
2748
- * a self-submitted accept of the caller's own offer throws.
2749
- */
2750
- export function assertPreparedEscrowAcceptMatches(preparedTransactionB64, expect) {
2751
- const decoded = decodePrepared(preparedTransactionB64);
2752
- // (1) Shared node-traversal invariant: EXACTLY ONE root and it IS the
2753
- // X402EscrowOffer_Accept exercise; EVERY node recognized; no orphan/extra-leg
2754
- // node. The honest accept's consequences (Archive of the consumed
2755
- // X402EscrowOffer + the Create of the X402Escrow — a Create node, not an
2756
- // exercise) are bound to the single Accept root by the reachability check; the
2757
- // only whitelisted consequence EXERCISE is Archive. A relay-injected outbound
2758
- // drain (Allocation_ExecuteTransfer / TransferFactory_Transfer /
2759
- // ExternalPartyAmuletRules_CreateTransferCommand / Allocation_Withdraw / a
2760
- // second root) is refused: it is neither the root choice nor a whitelisted
2761
- // consequence.
2762
- const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, ESCROW_ACCEPT_CHOICE, ESCROW_ACCEPT_CHOICE, ESCROW_ACCEPT_CONSEQUENCE_CHOICES);
2763
- // (2) Target-contract pin (CALLER INTENT — REQUIRED). The exercised
2764
- // Exercise.contract_id MUST equal the caller's offerCid: the core "accept
2765
- // exactly the offer I asked for" guarantee, closing the resolve→prepare TOCTOU
2766
- // (a relay resolving one offer to us then preparing the accept against another).
2767
- assertContractIdMatches(ex, expect.offerCid);
2768
- // (3) Pin WHICH template the choice runs against + the SIGNED synchronizer +
2769
- // timing metadata. Template/synchronizer are no-ops unless the caller pins them;
2770
- // timing is always sanity-bounded (relay-chosen validity window).
2771
- assertTemplateMatches(ex, expect.templateQualifiedName);
2772
- assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
2773
- assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
2774
- // (4) act_as / controller = own party (the sender). The submitter (the
2775
- // authoritative act_as) must be exactly the agent. The Daml controller is the
2776
- // offer's `sender`; since act_as is the authoritative submitter and the choice's
2777
- // sole controller is the sender, pinning act_as == selfParty proves the agent is
2778
- // the sender exercising its own accept — never a third party's authority.
2779
- assertActAsIsSender(decoded.actAs, expect.selfParty);
2780
- // (5) Foreign-party / no-outbound-leg backstop over the WHOLE signed message.
2781
- // The honest accept creates an `X402Escrow` signed by {facilitator, sender} and
2782
- // recording merchant + instrumentAdmin as reference fields; the consumed
2783
- // `X402EscrowOffer` (signatory facilitator, observer sender) is archived and
2784
- // appears in Metadata.input_contracts. So the ONLY parties that may appear
2785
- // anywhere are the agent (sender), the caller-pinned facilitator, and — when the
2786
- // honest escrow names them as distinct parties — the caller-pinned merchant +
2787
- // instrumentAdmin. EVERY allowed party is CALLER INTENT (selfParty/facilitator
2788
- // are required; merchant/instrumentAdmin are pinned when supplied) — none is
2789
- // taken from the relay. ANY other party anywhere (a relay-substituted
2790
- // facilitator that could later settle to itself, an injected outbound transfer
2791
- // leg creating an Amulet/Holding for a foreign payee, a foreign submitter leaf)
2792
- // is a smuggled redirect and is rejected. No admin/dso *root-position* exemption
2793
- // is threaded (the instrumentAdmin here is a plain reference field of the created
2794
- // escrow, allowed by exact-equality membership when pinned, not by a positional
2795
- // admin budget): zero allowed admin slots, so a relay-supplied value cannot be
2796
- // aliased to an attacker and smuggled in.
2797
- const allowed = new Set([expect.selfParty, expect.facilitator]);
2798
- if (expect.merchant !== undefined)
2799
- allowed.add(expect.merchant);
2800
- if (expect.instrumentAdmin !== undefined)
2801
- allowed.add(expect.instrumentAdmin);
2802
- const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
2803
- assertNoForeignParties(leaves, allowed, undefined /* no positional admin/dso exemption */, 0 /* zero allowed admin slots */, false /* untrusted */);
2804
- // (6) NUMERIC escrow-term pin the party backstop cannot see. The accept choice
2805
- // has no arguments; the escrow's terms live in the consequence Create of the
2806
- // X402Escrow. `amount` is a Daml Decimal (a Numeric leaf), invisible to the
2807
- // foreign-party backstop above — yet it is exactly what the facilitator's later
2808
- // `X402Escrow_Settle` pins the live allocation against. When the caller pins an
2809
- // amount we read the created escrow's recorded `amount` at its real declaration
2810
- // position and require canonical equality; fail closed otherwise. This closes
2811
- // the amount-swap trust-boundary gap: a malicious/buggy facilitator authoring
2812
- // the offer with an inflated amount (sender shown a small price) can no longer
2813
- // get the sender to blind-authorize an escrow that settles the larger value.
2814
- assertEscrowCreateAmountMatches(decoded, expect.amount, expect.facilitator, expect.selfParty);
2815
- }
2816
2328
  /* ════════════════════════════════════════════════════════════════════════
2817
2329
  * x402-direct (Design B "2-tx DIRECT") CONSENT CREATE — verify-before-sign for a
2818
2330
  * `SenderConsent` / `MerchantConsent` create (the party captures its one-time