@ftptech/canton-agent-wallet 0.1.19 → 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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pay.d.ts.map +1 -1
- package/dist/pay.js +10 -10
- package/dist/pay.js.map +1 -1
- package/dist/relay-client.d.ts +0 -54
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +0 -25
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-signer.d.ts +12 -15
- package/dist/relay-signer.d.ts.map +1 -1
- package/dist/relay-signer.js +27 -66
- package/dist/relay-signer.js.map +1 -1
- package/dist/tx.d.ts +11 -129
- package/dist/tx.d.ts.map +1 -1
- package/dist/tx.js +24 -242
- package/dist/tx.js.map +1 -1
- package/dist/verify-prepared.d.ts +0 -130
- package/dist/verify-prepared.d.ts.map +1 -1
- package/dist/verify-prepared.js +0 -488
- package/dist/verify-prepared.js.map +1 -1
- package/package.json +2 -2
package/dist/verify-prepared.js
CHANGED
|
@@ -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
|