@ftptech/canton-agent-wallet 0.5.1 → 0.6.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/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +0 -10
- package/dist/cli-args.js.map +1 -1
- package/dist/cli.js +10 -126
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/pay.d.ts.map +1 -1
- package/dist/pay.js +12 -11
- package/dist/pay.js.map +1 -1
- package/dist/relay-client.d.ts +0 -79
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +0 -27
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-signer.d.ts +10 -10
- package/dist/relay-signer.d.ts.map +1 -1
- package/dist/relay-signer.js +4 -72
- package/dist/relay-signer.js.map +1 -1
- package/dist/tx.d.ts +0 -148
- package/dist/tx.d.ts.map +1 -1
- package/dist/tx.js +9 -330
- package/dist/tx.js.map +1 -1
- package/dist/verify-prepared.d.ts +0 -153
- package/dist/verify-prepared.d.ts.map +1 -1
- package/dist/verify-prepared.js +0 -642
- package/dist/verify-prepared.js.map +1 -1
- package/package.json +2 -2
package/dist/verify-prepared.js
CHANGED
|
@@ -1227,101 +1227,6 @@ allowedConsequenceChoices = []) {
|
|
|
1227
1227
|
}
|
|
1228
1228
|
return { exercise: rootNode.exercise, rootNodeId: rootId };
|
|
1229
1229
|
}
|
|
1230
|
-
/**
|
|
1231
|
-
* Create-root analog of `assertSingleAllowedRootExercise`: assert the prepared
|
|
1232
|
-
* transaction is EXACTLY a single top-level CREATE of the allowed template, with
|
|
1233
|
-
* NO exercise anywhere (a value-moving choice is never a legitimate consequence of
|
|
1234
|
-
* a bare consent create), EVERY node recognized, exactly ONE root that IS that
|
|
1235
|
-
* create, and no orphan/extra-leg node.
|
|
1236
|
-
*
|
|
1237
|
-
* Used by the x402-direct onboarding arms (`createSenderConsent` /
|
|
1238
|
-
* `createMerchantConsent`): the SenderConsent / MerchantConsent are created by the
|
|
1239
|
-
* party SIGNING ITS OWN create (signatory = that party), so the prepared tx is a
|
|
1240
|
-
* single CreateCommand → one CREATE root node. Creating a standing consent MOVES NO
|
|
1241
|
-
* FUNDS; the ONLY parties it introduces are the signing party and the facilitator
|
|
1242
|
-
* (observer). A relay-injected EXERCISE (an Allocation_ExecuteTransfer /
|
|
1243
|
-
* TransferFactory_Transfer / CreateTransferCommand drain) or a second create/root is
|
|
1244
|
-
* refused. Fail-closed in every case.
|
|
1245
|
-
*/
|
|
1246
|
-
function assertSingleAllowedRootCreate(decoded,
|
|
1247
|
-
/** Caller-intent `module:entity` of the created template (e.g.
|
|
1248
|
-
* "X402Direct:SenderConsent"). Pinned against the create's decoded template when
|
|
1249
|
-
* present; a create carrying no decodable template is tolerated (normalized wire)
|
|
1250
|
-
* and identified by its principals at the call site. */
|
|
1251
|
-
expectedCreatedQualifiedName) {
|
|
1252
|
-
// (1) NO exercise of any choice anywhere. A consent create has no value-moving
|
|
1253
|
-
// consequence exercise; an exercise node is a relay-injected drain.
|
|
1254
|
-
if (decoded.exercises.length > 0) {
|
|
1255
|
-
throw new PreparedTransferMismatchError(`relay-prepared consent-create transaction contains ${decoded.exercises.length} ` +
|
|
1256
|
-
`exercise(s) ${decoded.exercises
|
|
1257
|
-
.map((e) => JSON.stringify(e.choiceId))
|
|
1258
|
-
.join(", ")} — expected a single bare create with no exercise — refusing to sign`);
|
|
1259
|
-
}
|
|
1260
|
-
// (2) Every node fully recognized (same fail-closed stance as the exercise arm).
|
|
1261
|
-
for (const node of decoded.nodes) {
|
|
1262
|
-
if (!node.recognizedVersion) {
|
|
1263
|
-
throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) carried ` +
|
|
1264
|
-
`under an unrecognized node version — refusing to sign`);
|
|
1265
|
-
}
|
|
1266
|
-
if (node.kind === undefined) {
|
|
1267
|
-
throw new PreparedTransferMismatchError(`relay-prepared transaction contains a node (id ${JSON.stringify(node.nodeId)}) with an ` +
|
|
1268
|
-
`unknown or ambiguous node type — refusing to sign`);
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
// (3) Exactly one root, and it must be the allowed CREATE.
|
|
1272
|
-
if (decoded.roots.length !== 1) {
|
|
1273
|
-
throw new PreparedTransferMismatchError(`relay-prepared transaction has ${decoded.roots.length} root nodes — expected exactly one ` +
|
|
1274
|
-
`(${JSON.stringify(expectedCreatedQualifiedName)} create) — refusing to sign`);
|
|
1275
|
-
}
|
|
1276
|
-
const byId = new Map();
|
|
1277
|
-
for (const node of decoded.nodes) {
|
|
1278
|
-
if (byId.has(node.nodeId)) {
|
|
1279
|
-
throw new PreparedTransferMismatchError(`relay-prepared transaction has duplicate node id ${JSON.stringify(node.nodeId)} — ` +
|
|
1280
|
-
`refusing to sign (ambiguous node graph)`);
|
|
1281
|
-
}
|
|
1282
|
-
byId.set(node.nodeId, node);
|
|
1283
|
-
}
|
|
1284
|
-
const rootId = decoded.roots[0];
|
|
1285
|
-
const rootNode = byId.get(rootId);
|
|
1286
|
-
if (rootNode === undefined) {
|
|
1287
|
-
throw new PreparedTransferMismatchError(`relay-prepared transaction root id ${JSON.stringify(rootId)} does not match any node — ` +
|
|
1288
|
-
`refusing to sign`);
|
|
1289
|
-
}
|
|
1290
|
-
if (rootNode.kind !== "create" || rootNode.create === undefined) {
|
|
1291
|
-
throw new PreparedTransferMismatchError(`relay-prepared transaction root node is not a ${JSON.stringify(expectedCreatedQualifiedName)} ` +
|
|
1292
|
-
`create (got node type ${JSON.stringify(rootNode.kind ?? "unknown")}) — refusing to sign`);
|
|
1293
|
-
}
|
|
1294
|
-
// Pin the created template's module:entity when the node carries one (a normalized
|
|
1295
|
-
// wire may omit it; the caller then self-anchors on the create's principals).
|
|
1296
|
-
if (rootNode.create.templateQualifiedName !== undefined &&
|
|
1297
|
-
rootNode.create.templateQualifiedName !== expectedCreatedQualifiedName) {
|
|
1298
|
-
throw new PreparedTransferMismatchError(`relay-prepared create is of template ${JSON.stringify(rootNode.create.templateQualifiedName)} — ` +
|
|
1299
|
-
`expected ${JSON.stringify(expectedCreatedQualifiedName)} — refusing to sign`);
|
|
1300
|
-
}
|
|
1301
|
-
// (4) No orphans: every node reachable from the single root.
|
|
1302
|
-
const reachable = new Set();
|
|
1303
|
-
const stack = [rootId];
|
|
1304
|
-
while (stack.length > 0) {
|
|
1305
|
-
const id = stack.pop();
|
|
1306
|
-
if (reachable.has(id))
|
|
1307
|
-
continue;
|
|
1308
|
-
reachable.add(id);
|
|
1309
|
-
const node = byId.get(id);
|
|
1310
|
-
if (node === undefined) {
|
|
1311
|
-
throw new PreparedTransferMismatchError(`relay-prepared transaction references child node id ${JSON.stringify(id)} that does not ` +
|
|
1312
|
-
`exist — refusing to sign`);
|
|
1313
|
-
}
|
|
1314
|
-
for (const child of node.children)
|
|
1315
|
-
stack.push(child);
|
|
1316
|
-
}
|
|
1317
|
-
const orphans = decoded.nodes.filter((nd) => !reachable.has(nd.nodeId));
|
|
1318
|
-
if (orphans.length > 0) {
|
|
1319
|
-
throw new PreparedTransferMismatchError(`relay-prepared transaction contains ${orphans.length} node(s) not reachable from the root ` +
|
|
1320
|
-
`(ids ${orphans.map((o) => JSON.stringify(o.nodeId)).join(", ")}) — refusing to sign ` +
|
|
1321
|
-
`(possible tampered/compromised relay hiding an extra leg)`);
|
|
1322
|
-
}
|
|
1323
|
-
return { create: rootNode.create, rootNodeId: rootId };
|
|
1324
|
-
}
|
|
1325
1230
|
/* ────────────────────────────────────────────────────────────────────────
|
|
1326
1231
|
* Shared SIGNED-Metadata checks (synchronizer + timing). Every field below is
|
|
1327
1232
|
* in the proto's "Metadata information that needs to be signed" block, so the
|
|
@@ -1908,553 +1813,6 @@ export function assertPreparedAcceptMatches(preparedTransactionB64, expect) {
|
|
|
1908
1813
|
// authority, never a third party's.
|
|
1909
1814
|
assertActAsIsSender(decoded.actAs, expect.selfParty);
|
|
1910
1815
|
}
|
|
1911
|
-
/* ════════════════════════════════════════════════════════════════════════
|
|
1912
|
-
* allocation-api (CIP-56 DVP) — verify-before-sign for the
|
|
1913
|
-
* `AllocationFactory_Allocate` exercise.
|
|
1914
|
-
*
|
|
1915
|
-
* THREAT MODEL — identical to the cip56 / v1 arms, for the Allocation path.
|
|
1916
|
-
* The agent asks the relay to PREPARE an `AllocationFactory_Allocate` (it LOCKS
|
|
1917
|
-
* the agent's input holdings into an Allocation naming the facilitator as
|
|
1918
|
-
* settlement.executor). A compromised relay could prepare a DIFFERENT
|
|
1919
|
-
* allocation — swap the receiver, inflate the amount, name an ATTACKER (not the
|
|
1920
|
-
* facilitator) as settlement.executor so the attacker can later steer
|
|
1921
|
-
* Allocation_ExecuteTransfer, or widen the deadline window — and hand back its
|
|
1922
|
-
* hash. Because the agent signs with its own key (the choice's sole controller
|
|
1923
|
-
* is allocation.transferLeg.sender = the payer), a blind signature would
|
|
1924
|
-
* authorize the attacker's allocation. We decode the prepared bytes with the
|
|
1925
|
-
* SAME structural rigor (Daml-LF POSITIONAL record reads via entryByDeclOrder,
|
|
1926
|
-
* fail-closed on duplicate/multi-member oneofs, parties matched BY TYPE not
|
|
1927
|
-
* text) and assert each money/escrow-critical leaf equals CALLER INTENT at its
|
|
1928
|
-
* declaration-order position.
|
|
1929
|
-
*
|
|
1930
|
-
* AllocationFactory_Allocate choiceArgument :: Record {
|
|
1931
|
-
* [0] expectedAdmin : Party, -- the instrument admin (DSO)
|
|
1932
|
-
* [1] allocation : AllocationSpecification,
|
|
1933
|
-
* [2] requestedAt : Time,
|
|
1934
|
-
* [3] inputHoldingCids : [ContractId Holding],
|
|
1935
|
-
* [4] extraArgs : ExtraArgs
|
|
1936
|
-
* }
|
|
1937
|
-
* AllocationSpecification :: Record {
|
|
1938
|
-
* [0] settlement : SettlementInfo {
|
|
1939
|
-
* [0] executor : Party, -- MUST == facilitatorParty (executor)
|
|
1940
|
-
* [1] settlementRef : Reference,
|
|
1941
|
-
* [2] requestedAt : Time,
|
|
1942
|
-
* [3] allocateBefore : Time, -- MUST be < settleBefore
|
|
1943
|
-
* [4] settleBefore : Time,
|
|
1944
|
-
* [5] meta : Metadata },
|
|
1945
|
-
* [1] transferLegId : Text,
|
|
1946
|
-
* [2] transferLeg : TransferLeg {
|
|
1947
|
-
* [0] sender : Party, -- the payer (sole controller; act_as)
|
|
1948
|
-
* [1] receiver : Party, -- MUST == merchant payTo from the 402
|
|
1949
|
-
* [2] amount : Numeric, -- MUST == the required amount
|
|
1950
|
-
* [3] instrumentId : InstrumentId { [0] admin : Party, [1] id : Text },
|
|
1951
|
-
* [4] meta : Metadata }
|
|
1952
|
-
* }
|
|
1953
|
-
*
|
|
1954
|
-
* FIELD-ORDER AUTHORITY (confirmed): the declared field ORDER above is CONFIRMED
|
|
1955
|
-
* against the canonical Splice DAML source — Daml-LF encodes records POSITIONALLY
|
|
1956
|
-
* by `with`-block field declaration order, so the canonical .daml IS the
|
|
1957
|
-
* authoritative ground truth for these positions:
|
|
1958
|
-
* - SettlementInfo, TransferLeg, AllocationSpecification, Reference:
|
|
1959
|
-
* token-standard/splice-api-token-allocation-v1/daml/Splice/Api/Token/AllocationV1.daml
|
|
1960
|
-
* - InstrumentId:
|
|
1961
|
-
* token-standard/splice-api-token-holding-v1/daml/Splice/Api/Token/HoldingV1.daml
|
|
1962
|
-
* - AllocationFactory_Allocate choice arg:
|
|
1963
|
-
* token-standard/splice-api-token-allocation-instruction-v1/daml/Splice/Api/Token/AllocationInstructionV1.daml
|
|
1964
|
-
* repo github.com/canton-network/splice @ commit
|
|
1965
|
-
* b1767c6e9aba37a278faadd50b17b3e11b153a3f (main, fetched 2026-06-23). Every
|
|
1966
|
-
* position pinned below matches that source verbatim (AllocationSpecification
|
|
1967
|
-
* has 3 fields with transferLegId at [1] between settlement[0] and
|
|
1968
|
-
* transferLeg[2]; SettlementInfo executor[0]/allocateBefore[3]/settleBefore[4];
|
|
1969
|
-
* TransferLeg sender[0]/receiver[1]/amount[2]/instrumentId[3];
|
|
1970
|
-
* InstrumentId admin[0]/id[1]; choice arg allocation at [1]).
|
|
1971
|
-
* Daml-LF binds records POSITIONALLY, so the positional reads below
|
|
1972
|
-
* (entryByDeclOrder) are the exact, fail-closed pin the cip56/v1 arms already
|
|
1973
|
-
* use; this is a STRUCTURAL pin by declaration order, not a best-effort substring
|
|
1974
|
-
* scan. Do NOT silently relax the positional reads to label-based ones to "make
|
|
1975
|
-
* it work" against a live shape — that reintroduces the label/position divergence
|
|
1976
|
-
* vector the cip56 arm documents. RESIDUAL: a live participant prepared-tx vector
|
|
1977
|
-
* remains a nice-to-have cross-check (DevNet infra-blocked: synchronizer topology
|
|
1978
|
-
* frozen since 2026-06-03) but is no longer blocking — the canonical source above
|
|
1979
|
-
* is the field-order authority.
|
|
1980
|
-
* ════════════════════════════════════════════════════════════════════════ */
|
|
1981
|
-
/** The allocate choice on an AllocationFactory (allocation-api / DVP). */
|
|
1982
|
-
const ALLOCATE_CHOICE = "AllocationFactory_Allocate";
|
|
1983
|
-
/** Choices the honest AllocationFactory_Allocate consequence subtree carries —
|
|
1984
|
-
* for Amulet the allocation creates an AmuletAllocation + LockedAmulet and
|
|
1985
|
-
* archives the consumed input Amulet(s). Permitted ONLY as reachable
|
|
1986
|
-
* consequences of the single Allocate root (never as a second root / orphan);
|
|
1987
|
-
* sender/receiver/amount/executor/admin stay pinned by extractAllocation and
|
|
1988
|
-
* the all-nodes foreign-party backstop still runs, so an injected redirect is
|
|
1989
|
-
* refused. Kept conservative (Archive only) like the strict pay paths; widen
|
|
1990
|
-
* ONLY against a confirmed live consequence subtree, never speculatively. */
|
|
1991
|
-
const ALLOCATE_CONSEQUENCE_CHOICES = ["Archive"];
|
|
1992
|
-
/**
|
|
1993
|
-
* Extract the allocation's money/escrow-critical leaves from the
|
|
1994
|
-
* `AllocationFactory_Allocate` choice argument, BY TYPE at each Daml
|
|
1995
|
-
* DECLARATION-ORDER POSITION (what the participant binds), NOT by label — with
|
|
1996
|
-
* the same label/position-divergence guard the cip56/v1 arms use. Fails closed
|
|
1997
|
-
* on any wrong-type / missing money-critical field.
|
|
1998
|
-
*/
|
|
1999
|
-
export function extractAllocation(chosenValue) {
|
|
2000
|
-
const top = recordEntries(chosenValue);
|
|
2001
|
-
// choiceArgument: [0]expectedAdmin [1]allocation [2]requestedAt
|
|
2002
|
-
// [3]inputHoldingCids [4]extraArgs — read position 1 ONLY.
|
|
2003
|
-
const allocationVal = entryByDeclOrder(top, 1, "allocation")?.value;
|
|
2004
|
-
if (!allocationVal) {
|
|
2005
|
-
throw new PreparedDecodeError("could not locate allocation record in AllocationFactory_Allocate choice argument");
|
|
2006
|
-
}
|
|
2007
|
-
// AllocationSpecification: [0]settlement [1]transferLegId [2]transferLeg.
|
|
2008
|
-
const spec = recordEntries(allocationVal);
|
|
2009
|
-
const settlementVal = entryByDeclOrder(spec, 0, "settlement")?.value;
|
|
2010
|
-
const transferLegVal = entryByDeclOrder(spec, 2, "transferLeg")?.value;
|
|
2011
|
-
if (!settlementVal)
|
|
2012
|
-
throw new PreparedDecodeError("allocation.settlement missing");
|
|
2013
|
-
if (!transferLegVal)
|
|
2014
|
-
throw new PreparedDecodeError("allocation.transferLeg missing");
|
|
2015
|
-
// SettlementInfo: [0]executor [1]settlementRef [2]requestedAt
|
|
2016
|
-
// [3]allocateBefore [4]settleBefore [5]meta.
|
|
2017
|
-
const settlement = recordEntries(settlementVal);
|
|
2018
|
-
const executorE = entryByDeclOrder(settlement, 0, "executor");
|
|
2019
|
-
const allocateBeforeE = entryByDeclOrder(settlement, 3, "allocateBefore");
|
|
2020
|
-
const settleBeforeE = entryByDeclOrder(settlement, 4, "settleBefore");
|
|
2021
|
-
const executor = executorE ? leafOf(executorE.value) : undefined;
|
|
2022
|
-
const allocateBefore = allocateBeforeE ? leafOf(allocateBeforeE.value) : undefined;
|
|
2023
|
-
const settleBefore = settleBeforeE ? leafOf(settleBeforeE.value) : undefined;
|
|
2024
|
-
if (!executor || executor.kind !== "party") {
|
|
2025
|
-
throw new PreparedDecodeError("allocation.settlement.executor is not a party");
|
|
2026
|
-
}
|
|
2027
|
-
// TransferLeg: [0]sender [1]receiver [2]amount [3]instrumentId [4]meta.
|
|
2028
|
-
const leg = recordEntries(transferLegVal);
|
|
2029
|
-
const senderE = entryByDeclOrder(leg, 0, "sender");
|
|
2030
|
-
const receiverE = entryByDeclOrder(leg, 1, "receiver");
|
|
2031
|
-
const amountE = entryByDeclOrder(leg, 2, "amount");
|
|
2032
|
-
const instrE = entryByDeclOrder(leg, 3, "instrumentId");
|
|
2033
|
-
const sender = senderE ? leafOf(senderE.value) : undefined;
|
|
2034
|
-
const receiver = receiverE ? leafOf(receiverE.value) : undefined;
|
|
2035
|
-
const amount = amountE ? leafOf(amountE.value) : undefined;
|
|
2036
|
-
const instrumentVal = instrE?.value;
|
|
2037
|
-
if (!sender || sender.kind !== "party") {
|
|
2038
|
-
throw new PreparedDecodeError("allocation.transferLeg.sender is not a party");
|
|
2039
|
-
}
|
|
2040
|
-
if (!receiver || receiver.kind !== "party") {
|
|
2041
|
-
throw new PreparedDecodeError("allocation.transferLeg.receiver is not a party");
|
|
2042
|
-
}
|
|
2043
|
-
if (!amount || amount.kind !== "numeric") {
|
|
2044
|
-
throw new PreparedDecodeError("allocation.transferLeg.amount is not numeric");
|
|
2045
|
-
}
|
|
2046
|
-
if (!instrumentVal)
|
|
2047
|
-
throw new PreparedDecodeError("allocation.transferLeg.instrumentId missing");
|
|
2048
|
-
const instrument = readInstrument(instrumentVal);
|
|
2049
|
-
return {
|
|
2050
|
-
sender: sender.value,
|
|
2051
|
-
receiver: receiver.value,
|
|
2052
|
-
amount: amount.value,
|
|
2053
|
-
instrumentAdmin: instrument.admin,
|
|
2054
|
-
instrumentId: instrument.id,
|
|
2055
|
-
executor: executor.value,
|
|
2056
|
-
...(allocateBefore && allocateBefore.kind === "timestamp"
|
|
2057
|
-
? { allocateBefore: allocateBefore.value }
|
|
2058
|
-
: {}),
|
|
2059
|
-
...(settleBefore && settleBefore.kind === "timestamp"
|
|
2060
|
-
? { settleBefore: settleBefore.value }
|
|
2061
|
-
: {}),
|
|
2062
|
-
};
|
|
2063
|
-
}
|
|
2064
|
-
/**
|
|
2065
|
-
* Assert the relay-returned `preparedTransaction` encodes EXACTLY the
|
|
2066
|
-
* `AllocationFactory_Allocate` the agent intended. Throws
|
|
2067
|
-
* `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError` if
|
|
2068
|
-
* the bytes are not a decodable PreparedTransaction carrying a single allocate
|
|
2069
|
-
* exercise. Call BEFORE signing the hash. Fail-closed: anything not positively
|
|
2070
|
-
* proven to match the intent throws.
|
|
2071
|
-
*/
|
|
2072
|
-
export function assertPreparedAllocationMatches(preparedTransactionB64, expect) {
|
|
2073
|
-
const decoded = decodePrepared(preparedTransactionB64);
|
|
2074
|
-
// Node-traversal invariant (shared with the cip56 / v1 arms): exactly one
|
|
2075
|
-
// allowed ROOT exercise & no other; EVERY node recognized; EXACTLY ONE root
|
|
2076
|
-
// that IS the single allowed AllocationFactory_Allocate exercise; no
|
|
2077
|
-
// orphan/extra-leg node. Honest Amulet consequence (Archive of the locked
|
|
2078
|
-
// input) whitelisted as a CONSEQUENCE only.
|
|
2079
|
-
const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, ALLOCATE_CHOICE, "allocation", ALLOCATE_CONSEQUENCE_CHOICES);
|
|
2080
|
-
const a = extractAllocation(ex.chosenValue);
|
|
2081
|
-
// OWN-PARTY SENDER PIN (hardening) — asserted FIRST, fail-CLOSED. The
|
|
2082
|
-
// AllocationFactory_Allocate controller is the sender, and the agent knows its
|
|
2083
|
-
// OWN party (wallet.party) independently of the relay. The extracted
|
|
2084
|
-
// transferLeg.sender MUST equal that own party. transferLeg.sender and
|
|
2085
|
-
// transferLeg.receiver are BOTH Party leaves at adjacent declaration-order
|
|
2086
|
-
// positions and the declared order is not yet confirmed against a live wire, so
|
|
2087
|
-
// a real-wire transposition of those two (or a wrong-position sender) could
|
|
2088
|
-
// otherwise slip past the type guard. Pinning the sender to the agent's own
|
|
2089
|
-
// party neutralizes that transposition risk REGARDLESS of field order — a swap
|
|
2090
|
-
// that puts any non-self party (e.g. the receiver/merchant) in the sender
|
|
2091
|
-
// position is refused here before anything else runs.
|
|
2092
|
-
if (a.sender !== expect.expectedSender) {
|
|
2093
|
-
throw new PreparedTransferMismatchError(`relay-prepared allocation transferLeg.sender (got ${JSON.stringify(a.sender)}) is not ` +
|
|
2094
|
-
`the agent's own party (${JSON.stringify(expect.expectedSender)}) — refusing to sign ` +
|
|
2095
|
-
`(possible sender/receiver transposition or wrong-position sender from a tampered/` +
|
|
2096
|
-
`compromised relay redirecting the locked funds)`);
|
|
2097
|
-
}
|
|
2098
|
-
const mismatches = [];
|
|
2099
|
-
if (a.sender !== expect.sender) {
|
|
2100
|
-
mismatches.push(`transferLeg.sender (got ${JSON.stringify(a.sender)}, intended ${JSON.stringify(expect.sender)})`);
|
|
2101
|
-
}
|
|
2102
|
-
if (a.receiver !== expect.receiver) {
|
|
2103
|
-
mismatches.push(`transferLeg.receiver (got ${JSON.stringify(a.receiver)}, intended ${JSON.stringify(expect.receiver)})`);
|
|
2104
|
-
}
|
|
2105
|
-
if (canonicalAmount(a.amount) !== canonicalAmount(expect.amount)) {
|
|
2106
|
-
mismatches.push(`transferLeg.amount (got ${JSON.stringify(a.amount)}, intended ${JSON.stringify(expect.amount)})`);
|
|
2107
|
-
}
|
|
2108
|
-
if (a.instrumentId !== expect.instrumentId) {
|
|
2109
|
-
mismatches.push(`transferLeg.instrumentId.id (got ${JSON.stringify(a.instrumentId)}, intended ${JSON.stringify(expect.instrumentId)})`);
|
|
2110
|
-
}
|
|
2111
|
-
// settlement.executor MUST equal the facilitator party (escrow-trust anchor).
|
|
2112
|
-
if (a.executor !== expect.executor) {
|
|
2113
|
-
mismatches.push(`settlement.executor (got ${JSON.stringify(a.executor)}, intended ${JSON.stringify(expect.executor)})`);
|
|
2114
|
-
}
|
|
2115
|
-
// Only pin the admin if the caller supplied an independently-trusted value.
|
|
2116
|
-
if (expect.instrumentAdmin !== undefined && a.instrumentAdmin !== expect.instrumentAdmin) {
|
|
2117
|
-
mismatches.push(`transferLeg.instrumentId.admin (got ${JSON.stringify(a.instrumentAdmin)}, intended ${JSON.stringify(expect.instrumentAdmin)})`);
|
|
2118
|
-
}
|
|
2119
|
-
if (mismatches.length > 0) {
|
|
2120
|
-
throw new PreparedTransferMismatchError(`relay-prepared allocation does not match intent: ${mismatches.join("; ")} — ` +
|
|
2121
|
-
`refusing to sign (possible tampered/compromised relay redirecting funds or executor)`);
|
|
2122
|
-
}
|
|
2123
|
-
// The admin/dso must never be aliased to a money/escrow role — otherwise a
|
|
2124
|
-
// relay could set the (unpinned) admin to the receiver/sender/executor and
|
|
2125
|
-
// have the backstop exempt that party. Reject up front.
|
|
2126
|
-
if (a.instrumentAdmin === expect.sender ||
|
|
2127
|
-
a.instrumentAdmin === expect.receiver ||
|
|
2128
|
-
a.instrumentAdmin === expect.executor) {
|
|
2129
|
-
throw new PreparedTransferMismatchError(`relay-prepared allocation instrumentId.admin ${JSON.stringify(a.instrumentAdmin)} equals a ` +
|
|
2130
|
-
`transfer/escrow party (sender/receiver/executor) — refusing to sign`);
|
|
2131
|
-
}
|
|
2132
|
-
// The executor must not be aliased to the sender or receiver — the executor is
|
|
2133
|
-
// the facilitator (a distinct party); collapsing it onto a money party would
|
|
2134
|
-
// defeat the escrow-trust separation.
|
|
2135
|
-
if (a.executor === expect.sender || a.executor === expect.receiver) {
|
|
2136
|
-
throw new PreparedTransferMismatchError(`relay-prepared allocation settlement.executor ${JSON.stringify(a.executor)} equals the ` +
|
|
2137
|
-
`sender or receiver — refusing to sign (escrow-trust separation broken)`);
|
|
2138
|
-
}
|
|
2139
|
-
// DEADLINE FAIL-CLOSED (hardening). Both allocateBefore AND settleBefore MUST
|
|
2140
|
-
// be present as Value.timestamp leaves. extractAllocation only populates these
|
|
2141
|
-
// when the leaf is actually a `timestamp`; a relay that OMITS a deadline leaf
|
|
2142
|
-
// or RE-TYPES it (e.g. a text/numeric/optional leaf at the settleBefore
|
|
2143
|
-
// position) makes the field read back undefined. Previously the ordering +
|
|
2144
|
-
// future-margin check was CONDITIONAL on both being present, so such a relay
|
|
2145
|
-
// could make the client skip the check entirely. Now a missing/wrong-type
|
|
2146
|
-
// deadline is itself a tamper signal: THROW (fail closed) rather than sign an
|
|
2147
|
-
// allocation with no enforceable settle window.
|
|
2148
|
-
if (a.allocateBefore === undefined || a.settleBefore === undefined) {
|
|
2149
|
-
throw new PreparedTransferMismatchError(`relay-prepared allocation is missing a timestamp deadline ` +
|
|
2150
|
-
`(allocateBefore present=${a.allocateBefore !== undefined}, ` +
|
|
2151
|
-
`settleBefore present=${a.settleBefore !== undefined}) — refusing to sign ` +
|
|
2152
|
-
`(a relay that omits or re-types a deadline leaf must not bypass the ` +
|
|
2153
|
-
`ordering + future-margin check)`);
|
|
2154
|
-
}
|
|
2155
|
-
// allocateBefore < settleBefore (deadline ordering). The spec REQUIRES
|
|
2156
|
-
// settleBefore strictly after allocateBefore; an inverted/equal window is a
|
|
2157
|
-
// tamper signal (it would make the allocation un-executable or immediately
|
|
2158
|
-
// reclaimable). Both are Value.timestamp µs since epoch.
|
|
2159
|
-
{
|
|
2160
|
-
let ab;
|
|
2161
|
-
let sb;
|
|
2162
|
-
try {
|
|
2163
|
-
ab = BigInt(a.allocateBefore);
|
|
2164
|
-
sb = BigInt(a.settleBefore);
|
|
2165
|
-
}
|
|
2166
|
-
catch {
|
|
2167
|
-
throw new PreparedTransferMismatchError(`relay-prepared allocation has a non-integer deadline — refusing to sign`);
|
|
2168
|
-
}
|
|
2169
|
-
if (!(ab < sb)) {
|
|
2170
|
-
throw new PreparedTransferMismatchError(`relay-prepared allocation has allocateBefore (${a.allocateBefore}) >= settleBefore ` +
|
|
2171
|
-
`(${a.settleBefore}) — refusing to sign (invalid/un-executable deadline window)`);
|
|
2172
|
-
}
|
|
2173
|
-
// settleBefore must be in the future — a lapsed window can never settle.
|
|
2174
|
-
const nowMs = expect.nowMs ?? Date.now();
|
|
2175
|
-
const settleMs = Number(sb / 1000n);
|
|
2176
|
-
if (Number.isFinite(settleMs) && settleMs <= nowMs) {
|
|
2177
|
-
throw new PreparedTransferMismatchError(`relay-prepared allocation settleBefore (${new Date(settleMs).toISOString()}) is in the ` +
|
|
2178
|
-
`past — refusing to sign (allocation could never be executed)`);
|
|
2179
|
-
}
|
|
2180
|
-
}
|
|
2181
|
-
// Pin WHICH template + WHICH contract the choice runs against, and the SIGNED
|
|
2182
|
-
// synchronizer + timing metadata. No-ops unless the caller supplies intent.
|
|
2183
|
-
assertTemplateMatches(ex, expect.templateQualifiedName);
|
|
2184
|
-
assertContractIdMatches(ex, expect.expectedContractId);
|
|
2185
|
-
assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
|
|
2186
|
-
assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
|
|
2187
|
-
// Cross-check the authoritative submitter: the allocate's sole controller is
|
|
2188
|
-
// transferLeg.sender = the agent, so act_as must be exactly the agent.
|
|
2189
|
-
assertActAsIsSender(decoded.actAs, expect.sender);
|
|
2190
|
-
// Position-aware foreign-party backstop over the WHOLE signed message. Allowed
|
|
2191
|
-
// recipients are {sender, receiver, executor} — the executor (facilitator) is a
|
|
2192
|
-
// LEGITIMATE party of the allocation (it is settlement.executor / observer), so
|
|
2193
|
-
// it must be in the allowed set or the honest allocation's own executor leaf
|
|
2194
|
-
// would surface as foreign. The instrument admin (DSO) is allowed at its known
|
|
2195
|
-
// root positions (expectedAdmin + instrumentId.admin = 2), value-excluded
|
|
2196
|
-
// OUTSIDE its root position ONLY when pinned to an independently-trusted value.
|
|
2197
|
-
const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
|
|
2198
|
-
assertNoForeignParties(leaves, new Set([expect.sender, expect.receiver, expect.executor]), a.instrumentAdmin, 2 /* expectedAdmin + instrumentId.admin */, expect.instrumentAdmin !== undefined /* trusted iff caller pinned it */);
|
|
2199
|
-
}
|
|
2200
|
-
/* ════════════════════════════════════════════════════════════════════════
|
|
2201
|
-
* allocation-api reclaim — verify-before-sign for `Allocation_Withdraw`
|
|
2202
|
-
* (Track B "locked funds always recoverable").
|
|
2203
|
-
*
|
|
2204
|
-
* THREAT MODEL. The agent has an open `AmuletAllocation` (it locked its own
|
|
2205
|
-
* holdings into an Allocation for a DvP that never settled / it wants to abort)
|
|
2206
|
-
* and wants its funds BACK. The canonical recovery choice on
|
|
2207
|
-
* `Splice.Api.Token.AllocationV1` is `Allocation_Withdraw`:
|
|
2208
|
-
*
|
|
2209
|
-
* choice Allocation_Withdraw : Allocation_WithdrawResult
|
|
2210
|
-
* controller (view this).allocation.transferLeg.sender -- the SENDER ALONE
|
|
2211
|
-
* with extraArgs : ExtraArgs
|
|
2212
|
-
* -- returns Allocation_WithdrawResult { senderHoldingCids, meta }
|
|
2213
|
-
* -- funds return to the sender BY CONSTRUCTION (no attacker-controllable
|
|
2214
|
-
* destination is encodable in the choice argument).
|
|
2215
|
-
* Confirmed against canton-network/splice@main, interface
|
|
2216
|
-
* Splice.Api.Token.AllocationV1.
|
|
2217
|
-
*
|
|
2218
|
-
* Even though the funds return to the sender by the choice's own definition, the
|
|
2219
|
-
* agent must NOT blind-sign a relay-prepared `Allocation_Withdraw`: a compromised
|
|
2220
|
-
* relay could instead prepare (a) a DIFFERENT choice that DOES move value to the
|
|
2221
|
-
* attacker (e.g. `Allocation_ExecuteTransfer`, a `TransferFactory_Transfer`
|
|
2222
|
-
* drain), (b) a withdraw of a DIFFERENT allocation than the one the caller
|
|
2223
|
-
* intended, (c) a withdraw with an extra/sibling outbound transfer leg as a
|
|
2224
|
-
* consequence, or (d) the same choice submitted under a foreign party's
|
|
2225
|
-
* authority. We therefore apply the SAME fail-closed structural gate the accept
|
|
2226
|
-
* (claim) arm uses — accept is the closest analog (funds-IN, structural,
|
|
2227
|
-
* own-party) — PLUS a REQUIRED contract-id pin (the caller names exactly which
|
|
2228
|
-
* allocation to reclaim, closing the resolve→prepare TOCTOU) and the all-nodes
|
|
2229
|
-
* foreign-party backstop with the ONLY allowed party being the agent itself
|
|
2230
|
-
* (Allocation_Withdraw returns funds to the sender, so ANY other party anywhere
|
|
2231
|
-
* — an injected outbound leg, a different receiver, an executor-as-payee — is a
|
|
2232
|
-
* smuggled redirect and is rejected). Anything not positively proven to be a
|
|
2233
|
-
* single self-submitted withdraw of the caller's own allocation throws.
|
|
2234
|
-
* ════════════════════════════════════════════════════════════════════════ */
|
|
2235
|
-
/** The reclaim choice on a Splice AmuletAllocation (funds-back / Track B). */
|
|
2236
|
-
const WITHDRAW_CHOICE = "Allocation_Withdraw";
|
|
2237
|
-
/** Choices the honest `Allocation_Withdraw` consequence subtree carries: it
|
|
2238
|
-
* archives the consumed `AmuletAllocation` / `LockedAmulet` and recreates the
|
|
2239
|
-
* sender's Amulet(s). Kept conservative (Archive only) like the strict pay /
|
|
2240
|
-
* allocate paths — a value-MOVING choice is never whitelisted here, so an
|
|
2241
|
-
* injected outbound drain is refused. Widen ONLY against a confirmed live
|
|
2242
|
-
* consequence subtree, never speculatively; the foreign-party backstop is the
|
|
2243
|
-
* real guard against a smuggled outbound leg regardless. */
|
|
2244
|
-
// Honest Allocation_Withdraw consequences. With an expire-lock choice-context the
|
|
2245
|
-
// withdraw unlocks the held Amulet back to its owner (the sender) via
|
|
2246
|
-
// LockedAmulet_UnlockV2 (older ledgers: LockedAmulet_Unlock) and archives the
|
|
2247
|
-
// consumed AmuletAllocation/LockedAmulet. These are SAFE to whitelist because the
|
|
2248
|
-
// step-5 foreign-party backstop still runs over the WHOLE signed message with the
|
|
2249
|
-
// agent (sender) as the ONLY permitted party — the unlock can therefore only
|
|
2250
|
-
// return funds to the sender, never redirect them. A relay-injected outbound drain
|
|
2251
|
-
// (Allocation_ExecuteTransfer / TransferFactory_Transfer / a second root) is still
|
|
2252
|
-
// neither the root choice nor a whitelisted consequence, so it is refused.
|
|
2253
|
-
const WITHDRAW_CONSEQUENCE_CHOICES = [
|
|
2254
|
-
"Archive",
|
|
2255
|
-
"LockedAmulet_UnlockV2",
|
|
2256
|
-
"LockedAmulet_Unlock",
|
|
2257
|
-
];
|
|
2258
|
-
/**
|
|
2259
|
-
* Assert the relay-returned `preparedTransaction` for the reclaim path encodes
|
|
2260
|
-
* EXACTLY a single `Allocation_Withdraw` exercise, on the caller's own
|
|
2261
|
-
* allocation cid, submitted by the agent — and is NOT a different (value-moving)
|
|
2262
|
-
* choice, a withdraw of a different contract, an outbound drain, or a
|
|
2263
|
-
* foreign-party submission. Throws `PreparedTransferMismatchError` on any
|
|
2264
|
-
* mismatch and `PreparedDecodeError` if the bytes are not a decodable
|
|
2265
|
-
* PreparedTransaction. Call BEFORE signing the hash. Fail-closed: anything not
|
|
2266
|
-
* positively proven to be a self-withdraw of the caller's own allocation throws.
|
|
2267
|
-
*/
|
|
2268
|
-
export function assertPreparedAllocationWithdrawMatches(preparedTransactionB64, expect) {
|
|
2269
|
-
const decoded = decodePrepared(preparedTransactionB64);
|
|
2270
|
-
// (1) Shared node-traversal invariant: EXACTLY ONE root and it IS the
|
|
2271
|
-
// Allocation_Withdraw exercise; EVERY node recognized; no orphan/extra-leg
|
|
2272
|
-
// node. The honest withdraw's settlement consequences (Archive of the consumed
|
|
2273
|
-
// AmuletAllocation / LockedAmulet) are whitelisted as CONSEQUENCES only — still
|
|
2274
|
-
// bound to the single Withdraw root by the reachability check. A relay-injected
|
|
2275
|
-
// outbound drain (Allocation_ExecuteTransfer / TransferFactory_Transfer /
|
|
2276
|
-
// CreateTransferCommand / a second root) is refused: it is neither the root
|
|
2277
|
-
// choice nor a whitelisted consequence.
|
|
2278
|
-
const { exercise: ex, rootNodeId } = assertSingleAllowedRootExercise(decoded, WITHDRAW_CHOICE, WITHDRAW_CHOICE, WITHDRAW_CONSEQUENCE_CHOICES);
|
|
2279
|
-
// (2) Target-contract pin (CALLER INTENT — REQUIRED). The exercised
|
|
2280
|
-
// Exercise.contract_id MUST equal the caller's allocationCid: this is the core
|
|
2281
|
-
// "withdraw exactly the allocation I asked for" guarantee and closes the
|
|
2282
|
-
// resolve→prepare TOCTOU (a relay resolving one allocation to us then preparing
|
|
2283
|
-
// the withdraw against another).
|
|
2284
|
-
assertContractIdMatches(ex, expect.allocationCid);
|
|
2285
|
-
// (3) SIGNED timing metadata sanity (relay-chosen validity window).
|
|
2286
|
-
assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
|
|
2287
|
-
// (4) act_as / controller = own party. The submitter (the authoritative
|
|
2288
|
-
// act_as) must be exactly the agent. The Daml controller is
|
|
2289
|
-
// allocation.transferLeg.sender; since act_as is the authoritative submitter
|
|
2290
|
-
// and the choice's sole controller is the sender, pinning act_as == selfParty
|
|
2291
|
-
// proves the agent is the sender exercising its own reclaim.
|
|
2292
|
-
assertActAsIsSender(decoded.actAs, expect.selfParty);
|
|
2293
|
-
// (5) Foreign-party / no-outbound-leg backstop over the WHOLE signed message.
|
|
2294
|
-
// Allocation_Withdraw returns funds to the SENDER by construction, so the ONLY
|
|
2295
|
-
// party permitted anywhere — root chosen_value, consequence/sibling nodes, any
|
|
2296
|
-
// node's party metadata, Metadata.input_contracts — is the agent itself. ANY
|
|
2297
|
-
// other party (an injected outbound transfer leg, a different receiver, an
|
|
2298
|
-
// executor-as-payee, a foreign submitter leaf) is a smuggled redirect and is
|
|
2299
|
-
// rejected. No admin/dso exemption is threaded (unlike accept/allocate): zero
|
|
2300
|
-
// allowed admin slots, so a relay-supplied DSO cannot be aliased to an attacker
|
|
2301
|
-
// and smuggled in. Widen ONLY if a confirmed live consequence subtree proves an
|
|
2302
|
-
// honest non-self party must appear — never speculatively.
|
|
2303
|
-
const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
|
|
2304
|
-
// Allowed parties: the sender (funds-back recipient) ALWAYS, plus — ONLY when
|
|
2305
|
-
// the caller pinned them (lock-expiry path) — the facilitator (the allocation
|
|
2306
|
-
// executor / lock holder, who is a stakeholder on the unlocked LockedAmulet and
|
|
2307
|
-
// on disclosed contracts), the DSO (AmuletRules signatory referenced by the
|
|
2308
|
-
// expire-lock context), and the RECEIVER (merchant). All are caller-intent, so a
|
|
2309
|
-
// relay cannot smuggle a foreign recipient by aliasing it to one of them. On the
|
|
2310
|
-
// no-lock (empty-context) path none is pinned and the set stays {sender} exactly.
|
|
2311
|
-
//
|
|
2312
|
-
// The receiver pin closes the "Design B" DIRECT gap: there the allocation's
|
|
2313
|
-
// receiver is the MERCHANT (not the facilitator, as in "Design A" escrow where
|
|
2314
|
-
// receiver == executor == facilitator), so the legitimate expire-lock
|
|
2315
|
-
// choice-context references the merchant party — which is none of
|
|
2316
|
-
// {self, facilitator, instrumentAdmin}, so the backstop would otherwise reject a
|
|
2317
|
-
// VALID withdraw. We add EXACTLY the caller-pinned receiver (never a wildcard),
|
|
2318
|
-
// so it remains fail-closed: an UNPINNED receiver is still rejected, and any
|
|
2319
|
-
// party other than the exact pinned receiver is still rejected. Design A omits it
|
|
2320
|
-
// (receiver == facilitator, already allowed) and behavior is unchanged.
|
|
2321
|
-
const allowedWithdraw = new Set([expect.selfParty]);
|
|
2322
|
-
if (expect.facilitator)
|
|
2323
|
-
allowedWithdraw.add(expect.facilitator);
|
|
2324
|
-
if (expect.receiver)
|
|
2325
|
-
allowedWithdraw.add(expect.receiver);
|
|
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 */);
|
|
2327
|
-
}
|
|
2328
|
-
/* ════════════════════════════════════════════════════════════════════════
|
|
2329
|
-
* x402-direct (Design B "2-tx DIRECT") CONSENT CREATE — verify-before-sign for a
|
|
2330
|
-
* `SenderConsent` / `MerchantConsent` create (the party captures its one-time
|
|
2331
|
-
* standing authorization).
|
|
2332
|
-
*
|
|
2333
|
-
* THREAT MODEL. In the direct flow each party signs ONE standing consent ever — the
|
|
2334
|
-
* SENDER a `SenderConsent {sender, facilitator}`, the MERCHANT a `MerchantConsent
|
|
2335
|
-
* {merchant, facilitator}` (source of truth: daml/x402-direct/daml/X402Direct.daml;
|
|
2336
|
-
* each template's signatory is the named party, observer = facilitator). The party
|
|
2337
|
-
* (an external party whose key the agent-wallet holds) creates it directly, so the
|
|
2338
|
-
* prepared tx is a single CreateCommand → one CREATE root. CREATING A STANDING
|
|
2339
|
-
* CONSENT MOVES NO FUNDS.
|
|
2340
|
-
*
|
|
2341
|
-
* Even so the agent must NOT blind-sign: a compromised relay could instead prepare
|
|
2342
|
-
* (a) a value-MOVING exercise (an `Allocation_ExecuteTransfer` /
|
|
2343
|
-
* `TransferFactory_Transfer` / `ExternalPartyAmuletRules_CreateTransferCommand` /
|
|
2344
|
-
* `Allocation_Withdraw` drain), (b) a consent naming a DIFFERENT facilitator (who
|
|
2345
|
-
* could later mint a both-party consent and settle the agent's allocations), (c) an
|
|
2346
|
-
* extra/sibling outbound leg, or (d) a foreign-party submission. We apply a
|
|
2347
|
-
* fail-closed CREATE-root gate (the create analog of the escrow-accept/withdraw
|
|
2348
|
-
* arms): EXACTLY one create of the expected template, NO exercise anywhere, act_as
|
|
2349
|
-
* == the signing party, the consent's [0] self-party == the agent AND [1]
|
|
2350
|
-
* facilitator == caller intent, and a foreign-party backstop whose ONLY allowed
|
|
2351
|
-
* parties are {selfParty, facilitator} (the consent's two parties). ANY other party
|
|
2352
|
-
* anywhere — a relay-substituted facilitator, an injected outbound leg, a foreign
|
|
2353
|
-
* submitter leaf — is rejected.
|
|
2354
|
-
*
|
|
2355
|
-
* WHAT AN ATTACKER-TAMPERED CONSENT CREATE TRIPS:
|
|
2356
|
-
* - swapped to a value-moving exercise → no-exercise + create-root pin
|
|
2357
|
-
* - second/sibling leg or extra root → single-root + no-orphan
|
|
2358
|
-
* - relay-substituted facilitator → [1] facilitator pin + backstop
|
|
2359
|
-
* - self-party not the agent → [0] self-party pin + act_as pin
|
|
2360
|
-
* - act_as a foreign party → act_as == selfParty pin
|
|
2361
|
-
* - any smuggled recipient party anywhere → foreign-party backstop
|
|
2362
|
-
* Anything not positively proven to be a single self-submitted create of the
|
|
2363
|
-
* caller's own consent among exactly {selfParty, facilitator} throws. Fail-closed.
|
|
2364
|
-
* ════════════════════════════════════════════════════════════════════════ */
|
|
2365
|
-
/** Created template `module:entity` names for the two standing consents. */
|
|
2366
|
-
const SENDER_CONSENT_CREATED_QUALIFIED_NAME = "X402Direct:SenderConsent";
|
|
2367
|
-
const MERCHANT_CONSENT_CREATED_QUALIFIED_NAME = "X402Direct:MerchantConsent";
|
|
2368
|
-
/** Read the {self, facilitator} parties of a consent create-args record at their
|
|
2369
|
-
* declaration positions ([0] self, [1] facilitator). Both SenderConsent and
|
|
2370
|
-
* MerchantConsent share this shape (the [0] label differs: sender vs merchant).
|
|
2371
|
-
* Returns undefined if the record is not the consent shape at those positions. */
|
|
2372
|
-
function readConsentCreatePrincipals(argument, selfLabel) {
|
|
2373
|
-
let entries;
|
|
2374
|
-
try {
|
|
2375
|
-
entries = recordEntries(argument);
|
|
2376
|
-
}
|
|
2377
|
-
catch {
|
|
2378
|
-
return undefined;
|
|
2379
|
-
}
|
|
2380
|
-
let self;
|
|
2381
|
-
let facilitator;
|
|
2382
|
-
try {
|
|
2383
|
-
const selfE = entryByDeclOrder(entries, 0, selfLabel);
|
|
2384
|
-
const facE = entryByDeclOrder(entries, 1, "facilitator");
|
|
2385
|
-
self = selfE ? leafOf(selfE.value) : undefined;
|
|
2386
|
-
facilitator = facE ? leafOf(facE.value) : undefined;
|
|
2387
|
-
}
|
|
2388
|
-
catch {
|
|
2389
|
-
return undefined;
|
|
2390
|
-
}
|
|
2391
|
-
if (!self || self.kind !== "party")
|
|
2392
|
-
return undefined;
|
|
2393
|
-
if (!facilitator || facilitator.kind !== "party")
|
|
2394
|
-
return undefined;
|
|
2395
|
-
return { self: self.value, facilitator: facilitator.value };
|
|
2396
|
-
}
|
|
2397
|
-
/**
|
|
2398
|
-
* Assert the relay-returned `preparedTransaction` for the x402-direct consent-create
|
|
2399
|
-
* path encodes EXACTLY a single `SenderConsent` / `MerchantConsent` create, signed
|
|
2400
|
-
* by the agent, naming {selfParty, facilitator} — and is NOT a value-moving choice,
|
|
2401
|
-
* a second leg, a relay-substituted facilitator, or a foreign-party submission.
|
|
2402
|
-
* Throws `PreparedTransferMismatchError` on any mismatch and `PreparedDecodeError`
|
|
2403
|
-
* if the bytes are not a decodable PreparedTransaction. Call BEFORE signing. Fail-
|
|
2404
|
-
* closed: anything not positively proven to be a self-submitted consent create among
|
|
2405
|
-
* exactly {selfParty, facilitator} throws.
|
|
2406
|
-
*/
|
|
2407
|
-
export function assertPreparedConsentCreateMatches(preparedTransactionB64, expect) {
|
|
2408
|
-
const decoded = decodePrepared(preparedTransactionB64);
|
|
2409
|
-
const selfLabel = expect.consent === "sender" ? "sender" : "merchant";
|
|
2410
|
-
const expectedCreatedQualifiedName = expect.consent === "sender"
|
|
2411
|
-
? SENDER_CONSENT_CREATED_QUALIFIED_NAME
|
|
2412
|
-
: MERCHANT_CONSENT_CREATED_QUALIFIED_NAME;
|
|
2413
|
-
// (1) Shared CREATE-root invariant: EXACTLY one create of the expected consent
|
|
2414
|
-
// template, NO exercise anywhere, every node recognized, no orphan/extra-leg node.
|
|
2415
|
-
const { create, rootNodeId } = assertSingleAllowedRootCreate(decoded, expectedCreatedQualifiedName);
|
|
2416
|
-
// (2) Pin the SIGNED synchronizer + timing metadata (relay-chosen validity
|
|
2417
|
-
// window). Synchronizer is a no-op unless the caller pins it; timing is always
|
|
2418
|
-
// sanity-bounded.
|
|
2419
|
-
assertSynchronizerMatches(decoded.synchronizerId, expect.synchronizerId);
|
|
2420
|
-
assertTimingPlausible(decoded, expect.nowMs ?? Date.now());
|
|
2421
|
-
// (3) act_as / signatory = own party. The consent's sole signatory is the signing
|
|
2422
|
-
// party; act_as is the authoritative submitter, so act_as == selfParty proves the
|
|
2423
|
-
// agent is creating its OWN consent (never a third party's authority).
|
|
2424
|
-
assertActAsIsSender(decoded.actAs, expect.selfParty);
|
|
2425
|
-
// (4) The created consent's principals at their declaration positions: [0] the
|
|
2426
|
-
// self-party (sender/merchant) MUST equal the agent, [1] facilitator MUST equal
|
|
2427
|
-
// caller intent. This pins WHO the consent binds — a relay-substituted facilitator
|
|
2428
|
-
// (who could later mint a both-party consent and settle the agent's allocations)
|
|
2429
|
-
// or a self-party other than the agent is rejected here even before the backstop.
|
|
2430
|
-
if (create.argument === undefined) {
|
|
2431
|
-
throw new PreparedTransferMismatchError(`relay-prepared consent create carries no decodable argument — refusing to sign ` +
|
|
2432
|
-
`(cannot verify the consent's {${selfLabel}, facilitator})`);
|
|
2433
|
-
}
|
|
2434
|
-
const principals = readConsentCreatePrincipals(create.argument, selfLabel);
|
|
2435
|
-
if (principals === undefined) {
|
|
2436
|
-
throw new PreparedTransferMismatchError(`relay-prepared consent create argument is not the {${selfLabel}, facilitator} record ` +
|
|
2437
|
-
`shape at positions [0],[1] — refusing to sign`);
|
|
2438
|
-
}
|
|
2439
|
-
if (principals.self !== expect.selfParty) {
|
|
2440
|
-
throw new PreparedTransferMismatchError(`relay-prepared consent ${selfLabel} is ${JSON.stringify(principals.self)} — expected the ` +
|
|
2441
|
-
`agent ${JSON.stringify(expect.selfParty)} — refusing to sign`);
|
|
2442
|
-
}
|
|
2443
|
-
if (principals.facilitator !== expect.facilitator) {
|
|
2444
|
-
throw new PreparedTransferMismatchError(`relay-prepared consent facilitator is ${JSON.stringify(principals.facilitator)} — expected ` +
|
|
2445
|
-
`${JSON.stringify(expect.facilitator)} — refusing to sign (a relay-substituted facilitator ` +
|
|
2446
|
-
`could later mint a both-party consent and settle the agent's allocations)`);
|
|
2447
|
-
}
|
|
2448
|
-
// (5) Foreign-party backstop over the WHOLE signed message. The honest consent
|
|
2449
|
-
// create introduces ONLY {selfParty, facilitator} (signatory + observer); the
|
|
2450
|
-
// create root has no exercise, so all its party leaves are scanned under
|
|
2451
|
-
// `elsewhere`. ANY other party anywhere is a smuggled redirect. Both allowed
|
|
2452
|
-
// parties are CALLER INTENT; zero admin/dso slots (a consent has no instrument
|
|
2453
|
-
// admin), so a relay-supplied value cannot be aliased in.
|
|
2454
|
-
const allowed = new Set([expect.selfParty, expect.facilitator]);
|
|
2455
|
-
const leaves = collectSplitPartyLeaves(decoded, rootNodeId);
|
|
2456
|
-
assertNoForeignParties(leaves, allowed, undefined /* no admin/dso exemption */, 0 /* zero allowed admin slots */, false /* untrusted */);
|
|
2457
|
-
}
|
|
2458
1816
|
/** Thrown when a supplied `recomputeHash` cannot faithfully encode the bytes. */
|
|
2459
1817
|
export class PreparedHashUnavailableError extends Error {
|
|
2460
1818
|
constructor(message) {
|