@correntelabs/beeai-ashlar-bridge 1.0.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/README.md +75 -0
- package/STRESS_REPORT.md +71 -0
- package/dist/packages/beeai-ashlar-bridge/src/ashlar-tool.d.ts +84 -0
- package/dist/packages/beeai-ashlar-bridge/src/ashlar-tool.js +202 -0
- package/dist/packages/beeai-ashlar-bridge/src/beehive-swarm.d.ts +1 -0
- package/dist/packages/beeai-ashlar-bridge/src/beehive-swarm.js +157 -0
- package/dist/packages/beeai-ashlar-bridge/src/catalog-tool.d.ts +28 -0
- package/dist/packages/beeai-ashlar-bridge/src/catalog-tool.js +64 -0
- package/dist/packages/beeai-ashlar-bridge/src/compliance-tool.d.ts +34 -0
- package/dist/packages/beeai-ashlar-bridge/src/compliance-tool.js +73 -0
- package/dist/packages/beeai-ashlar-bridge/src/demo-agent.d.ts +1 -0
- package/dist/packages/beeai-ashlar-bridge/src/demo-agent.js +149 -0
- package/dist/packages/beeai-ashlar-bridge/src/index.d.ts +3 -0
- package/dist/packages/beeai-ashlar-bridge/src/index.js +3 -0
- package/dist/packages/beeai-ashlar-bridge/src/large-swarm-stress.d.ts +1 -0
- package/dist/packages/beeai-ashlar-bridge/src/large-swarm-stress.js +152 -0
- package/dist/src/x402/mandate.d.ts +449 -0
- package/dist/src/x402/mandate.js +1234 -0
- package/dist/src/x402/manifest-sig.d.ts +163 -0
- package/dist/src/x402/manifest-sig.js +259 -0
- package/dist/src/x402/merkle-transcript.d.ts +73 -0
- package/dist/src/x402/merkle-transcript.js +159 -0
- package/package.json +37 -0
- package/src/ashlar-tool.ts +277 -0
- package/src/beehive-swarm.ts +172 -0
- package/src/catalog-tool.ts +80 -0
- package/src/compliance-tool.ts +89 -0
- package/src/demo-agent.ts +163 -0
- package/src/index.ts +3 -0
- package/src/large-swarm-stress.ts +180 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402-mandate/1 — the authority a payment carries, not just the payment.
|
|
3
|
+
*
|
|
4
|
+
* x402 today proves "money moved." It says nothing about whether the agent
|
|
5
|
+
* that moved it was ALLOWED to. A Mandate is the missing half: a compact,
|
|
6
|
+
* signed capability a principal (a human, or a parent agent) issues to an
|
|
7
|
+
* agent —
|
|
8
|
+
*
|
|
9
|
+
* "spend up to CAP, to recipients in R, for purpose P, until T"
|
|
10
|
+
*
|
|
11
|
+
* — whose digest is bound into the x402 payment preimage, so a single receipt
|
|
12
|
+
* proves BOTH that money moved AND that the payer held authority for exactly
|
|
13
|
+
* this action, within these bounds. Verification is OFFLINE (no callback) and
|
|
14
|
+
* PORTABLE across schemes and chains, because a Mandate constrains AUTHORITY
|
|
15
|
+
* and never touches SETTLEMENT.
|
|
16
|
+
*
|
|
17
|
+
* ── THE TRUST CHAIN (who is allowed to say what) ──
|
|
18
|
+
* The issuer-signed Mandate is the single trust root. It names, IN the signed
|
|
19
|
+
* bytes, the ACCOUNTANT that commits spend heads: either one Ed25519 key
|
|
20
|
+
* (Model A — a named notary/issuer/custodian, the model regulated deployments
|
|
21
|
+
* ask for) or the literal 'payees' (Model B — no single accountant; every
|
|
22
|
+
* entry is co-signed by its payee). A head is only acceptable when its own
|
|
23
|
+
* accountant field MATCHES the mandate's and its signature verifies against
|
|
24
|
+
* that key — a presenter can never introduce "the accountant" themselves.
|
|
25
|
+
*
|
|
26
|
+
* ── WHAT EACH CHECK PROVES, STATED HONESTLY ──
|
|
27
|
+
* checkPayment — ONE payment's authority: grant signature, digest binding,
|
|
28
|
+
* payer, recipient scope, asset, per-payment bound, cap ceiling, expiry on the
|
|
29
|
+
* VERIFIER's clock. It does not touch accounting.
|
|
30
|
+
*
|
|
31
|
+
* verifySpendWithinCap — a presented history is INTERNALLY sound: mandate-
|
|
32
|
+
* bound, scope-checked, duplicate-free, append-only-chained, within cap. A
|
|
33
|
+
* truncated PREFIX still passes — this alone is tamper-evidence, not
|
|
34
|
+
* completeness.
|
|
35
|
+
*
|
|
36
|
+
* verifySpendAgainstCommitment (Model A) — the presented history reproduces an
|
|
37
|
+
* accountant-committed head (root AND total), with the accountant taken from
|
|
38
|
+
* the mandate. Completeness holds ONLY against a freshness floor: pass the
|
|
39
|
+
* highest seq you have accepted (or read the highest anchored seq from Layer
|
|
40
|
+
* 2). Passing lastSeq=null is an explicit downgrade to tamper-evidence and is
|
|
41
|
+
* warned as such — a first-contact verifier without an anchor lookup or a
|
|
42
|
+
* challenge-response gets NO completeness guarantee.
|
|
43
|
+
*
|
|
44
|
+
* verifySpendWithPayeeAttestations (Model B) — every presented entry is
|
|
45
|
+
* co-signed by its payee (recipient identity IS the payee's Ed25519 key), so
|
|
46
|
+
* FABRICATION is impossible. Entry-backing ALONE does not detect omission:
|
|
47
|
+
* dropping an entry plus its attestation passes. Omission-resistance comes
|
|
48
|
+
* from reconcilePayeeEvidence below — the verifier reconciles the presented
|
|
49
|
+
* log against payee evidence it gathered INDEPENDENTLY of the agent (from the
|
|
50
|
+
* payees, or by scanning their anchors): admitted attestations absent from
|
|
51
|
+
* the log refuse; the attested floor binding the cap refuses; payee
|
|
52
|
+
* equivocation refuses; junk/foreign attestations warn but never veto.
|
|
53
|
+
*
|
|
54
|
+
* ── COUNTERPARTY MUST-RULES (the protocol the checks assume) ──
|
|
55
|
+
* A counterparty that wants the cap to bind MUST: (1) before settling, obtain
|
|
56
|
+
* the current head and require committedTotal + amount <= cap; (2) after
|
|
57
|
+
* settling, require a NEW head with seq > previous whose log includes THIS
|
|
58
|
+
* paymentId (verifyPaymentCommitted below); (3) treat failure to produce it as
|
|
59
|
+
* default. An authorized payment that is never committed is otherwise
|
|
60
|
+
* invisible to every later verifier.
|
|
61
|
+
*
|
|
62
|
+
* LAYER 2: commitmentAnchorCalldata anchors each head on Coston2 (the evidence
|
|
63
|
+
* lane's x402note/1 grammar, now carrying `by=<accountant>`), which makes an
|
|
64
|
+
* equivocating accountant publicly detectable and gives first-contact
|
|
65
|
+
* verifiers a freshness source. Anchoring is permissionless; an anchor proves
|
|
66
|
+
* WHEN a head existed, never that its contents are honest — verification
|
|
67
|
+
* always re-runs the trust chain above.
|
|
68
|
+
*
|
|
69
|
+
* Amounts are INTEGER minor-unit decimal strings (exact BigInt arithmetic, no
|
|
70
|
+
* float in a money path). NOTE (quiet): intentionally NOT in the published SDK.
|
|
71
|
+
*/
|
|
72
|
+
import { createHash, createPublicKey, verify as edVerify } from 'node:crypto';
|
|
73
|
+
import { jcsCanonical, assertJcsSafe } from './manifest-sig.js';
|
|
74
|
+
import { transcriptRoot, inclusionProof, verifyInclusion, EMPTY_ROOT, } from './merkle-transcript.js';
|
|
75
|
+
export const MANDATE_VERSION = 'x402-mandate/1';
|
|
76
|
+
/** Domain-separation tag: the signed/hashed input is TAG || JCS(mandate). */
|
|
77
|
+
const DS_TAG = Buffer.from(MANDATE_VERSION + '\n', 'utf8');
|
|
78
|
+
/** Recipient sentinel: an EXPLICIT, signed opt-in to an unconstrained payee set. Must be the SOLE element. */
|
|
79
|
+
export const ANY_RECIPIENT = '*';
|
|
80
|
+
/** Accountant sentinel selecting Model B: spend entries are co-signed by their payees; no single accountant key. */
|
|
81
|
+
export const PAYEES_ACCOUNTANT = 'payees';
|
|
82
|
+
/** 32-byte Ed25519 public key, base64url, unpadded (43 chars). */
|
|
83
|
+
const B64URL_KEY = /^[A-Za-z0-9_-]{43}$/;
|
|
84
|
+
const SHA256_PREFIXED = /^sha256:[0-9a-f]{64}$/;
|
|
85
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
86
|
+
/**
|
|
87
|
+
* paymentId charset: non-empty, 1–64 chars, excludes the anchor-grammar
|
|
88
|
+
* separators (';' '@' '#' '=') and newline so it is safe both in the §7 binding
|
|
89
|
+
* preimage and embedded raw in the §10 payee-anchor calldata (…@<paymentId>;by=).
|
|
90
|
+
*/
|
|
91
|
+
const PAYMENT_ID = /^[A-Za-z0-9._~-]{1,64}$/;
|
|
92
|
+
/** True iff a string is well-formed UTF-16 (no unpaired surrogate). Feature-detects for Node <20. */
|
|
93
|
+
const isWellFormedUtf16 = (s) => typeof s.isWellFormed === 'function'
|
|
94
|
+
? s.isWellFormed()
|
|
95
|
+
: !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
|
|
96
|
+
const INT = /^(0|[1-9][0-9]*)$/;
|
|
97
|
+
const isInt = (s) => typeof s === 'string' && INT.test(s);
|
|
98
|
+
const assertInt = (s, field) => {
|
|
99
|
+
if (!isInt(s))
|
|
100
|
+
throw new Error(`${field} must be a non-negative integer minor-unit string, got ${JSON.stringify(s)}`);
|
|
101
|
+
return BigInt(s);
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Strict RFC 3339 UTC ('...Z'). Shape-checked, parsed, then ROUND-TRIPPED so a
|
|
105
|
+
* shape-valid-but-overflowing date (2026-02-30, hour 24) that Date.parse would
|
|
106
|
+
* silently normalize is rejected instead. Never local-time-parses.
|
|
107
|
+
*/
|
|
108
|
+
const RFC3339_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
109
|
+
function parseUtc(s) {
|
|
110
|
+
if (typeof s !== 'string' || !RFC3339_UTC.test(s))
|
|
111
|
+
return null;
|
|
112
|
+
const t = Date.parse(s);
|
|
113
|
+
if (Number.isNaN(t))
|
|
114
|
+
return null;
|
|
115
|
+
// Round-trip: normalize both sides to millisecond precision and compare.
|
|
116
|
+
const canonical = new Date(t).toISOString(); // always .SSSZ
|
|
117
|
+
const padded = s.includes('.')
|
|
118
|
+
? s.replace(/\.(\d{1,3})Z$/, (_, f) => '.' + f.padEnd(3, '0') + 'Z')
|
|
119
|
+
: s.replace(/Z$/, '.000Z');
|
|
120
|
+
return canonical === padded ? t : null;
|
|
121
|
+
}
|
|
122
|
+
/** Closed key sets — unknown members are rejected on all three signed objects (§18). */
|
|
123
|
+
const MANDATE_KEYS = new Set(['v', 'issuer', 'subject', 'asset', 'cap', 'perPayment', 'recipients', 'accountant', 'purpose', 'notAfter', 'nonce', 'parent']);
|
|
124
|
+
const COMMIT_KEYS = new Set(['v', 'mandateDigest', 'accountant', 'seq', 'root', 'total', 'at']);
|
|
125
|
+
const ATT_KEYS = new Set(['v', 'mandateDigest', 'paymentId', 'payee', 'asset', 'amount', 'at']);
|
|
126
|
+
/** First unknown key in `obj` not in `allowed`, or null. */
|
|
127
|
+
const unknownKey = (obj, allowed) => {
|
|
128
|
+
for (const k of Object.keys(obj))
|
|
129
|
+
if (!allowed.has(k))
|
|
130
|
+
return k;
|
|
131
|
+
return null;
|
|
132
|
+
};
|
|
133
|
+
/** Structural validation. Returns an error string, or null when well-formed. */
|
|
134
|
+
function mandateShapeError(m) {
|
|
135
|
+
if (!m || typeof m !== 'object')
|
|
136
|
+
return 'mandate is not an object';
|
|
137
|
+
const x = m;
|
|
138
|
+
if (x.v !== MANDATE_VERSION)
|
|
139
|
+
return `mandate.v must be ${MANDATE_VERSION}`;
|
|
140
|
+
for (const k of Object.keys(x)) {
|
|
141
|
+
if (!MANDATE_KEYS.has(k))
|
|
142
|
+
return `mandate carries an unknown member '${k}' — the object is closed (fail closed on unknown fields)`;
|
|
143
|
+
}
|
|
144
|
+
for (const f of ['issuer', 'subject', 'asset', 'cap', 'accountant', 'purpose', 'notAfter', 'nonce']) {
|
|
145
|
+
if (typeof x[f] !== 'string')
|
|
146
|
+
return `mandate.${f} must be a string`;
|
|
147
|
+
if (!isWellFormedUtf16(x[f]))
|
|
148
|
+
return `mandate.${f} is not well-formed UTF-16 (unpaired surrogate)`;
|
|
149
|
+
}
|
|
150
|
+
if (x.parent !== undefined && typeof x.parent === 'string' && !isWellFormedUtf16(x.parent))
|
|
151
|
+
return 'mandate.parent is not well-formed UTF-16';
|
|
152
|
+
if (!B64URL_KEY.test(x.issuer))
|
|
153
|
+
return 'mandate.issuer must be a base64url Ed25519 public key';
|
|
154
|
+
if (!isInt(x.cap))
|
|
155
|
+
return 'mandate.cap must be an integer minor-unit string';
|
|
156
|
+
if (x.perPayment !== undefined && !isInt(x.perPayment))
|
|
157
|
+
return 'mandate.perPayment must be an integer minor-unit string';
|
|
158
|
+
if (x.perPayment !== undefined && BigInt(x.perPayment) > BigInt(x.cap)) {
|
|
159
|
+
return 'mandate.perPayment exceeds cap — a per-payment bound above the cumulative cap is incoherent';
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(x.recipients) || !x.recipients.every((r) => typeof r === 'string'))
|
|
162
|
+
return 'mandate.recipients must be a string[]';
|
|
163
|
+
if (!x.recipients.every(isWellFormedUtf16))
|
|
164
|
+
return 'mandate.recipients contains a value that is not well-formed UTF-16';
|
|
165
|
+
if (x.recipients.length === 0)
|
|
166
|
+
return 'mandate.recipients is empty — use [ANY_RECIPIENT] for an explicit unconstrained opt-in';
|
|
167
|
+
if (x.recipients.includes(ANY_RECIPIENT) && x.recipients.length > 1) {
|
|
168
|
+
return `'${ANY_RECIPIENT}' must be the SOLE element to opt into an unconstrained recipient set`;
|
|
169
|
+
}
|
|
170
|
+
const acct = x.accountant;
|
|
171
|
+
if (acct !== PAYEES_ACCOUNTANT && !B64URL_KEY.test(acct)) {
|
|
172
|
+
return `mandate.accountant must be a base64url Ed25519 key (Model A) or '${PAYEES_ACCOUNTANT}' (Model B)`;
|
|
173
|
+
}
|
|
174
|
+
if (acct === PAYEES_ACCOUNTANT && !x.recipients.every((r) => r === ANY_RECIPIENT || B64URL_KEY.test(r))) {
|
|
175
|
+
return "under the 'payees' accountant every recipient must itself be a base64url Ed25519 key (the identity IS the attesting key)";
|
|
176
|
+
}
|
|
177
|
+
if (parseUtc(x.notAfter) === null)
|
|
178
|
+
return 'mandate.notAfter must be strict RFC 3339 UTC (…Z)';
|
|
179
|
+
if (x.parent !== undefined && (typeof x.parent !== 'string' || !SHA256_PREFIXED.test(x.parent))) {
|
|
180
|
+
return 'mandate.parent must be sha256:<64-hex> (the parent mandate digest)';
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
function canonicalBytes(m) {
|
|
185
|
+
assertJcsSafe(m);
|
|
186
|
+
return Buffer.concat([DS_TAG, Buffer.from(jcsCanonical(m), 'utf8')]);
|
|
187
|
+
}
|
|
188
|
+
/** The digest a payment binds to (domain-separated). */
|
|
189
|
+
export function mandateDigest(m) {
|
|
190
|
+
return 'sha256:' + createHash('sha256').update(canonicalBytes(m)).digest('hex');
|
|
191
|
+
}
|
|
192
|
+
/** True iff a recipient is allowed by the mandate's scope. */
|
|
193
|
+
function recipientAllowed(m, recipient) {
|
|
194
|
+
if (m.recipients.length === 1 && m.recipients[0] === ANY_RECIPIENT)
|
|
195
|
+
return true;
|
|
196
|
+
return m.recipients.includes(recipient);
|
|
197
|
+
}
|
|
198
|
+
function ed25519Verify(bytes, publicKeyB64url, sigB64url) {
|
|
199
|
+
try {
|
|
200
|
+
const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: publicKeyB64url }, format: 'jwk' });
|
|
201
|
+
return edVerify(null, bytes, key, Buffer.from(sigB64url, 'base64url'));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Sign a mandate with the principal's key. Rejects malformed grants, perPayment>cap, and a signer that is not the issuer. */
|
|
208
|
+
export function issueMandate(m, signer) {
|
|
209
|
+
const shape = mandateShapeError(m);
|
|
210
|
+
if (shape)
|
|
211
|
+
throw new Error(`refusing to issue a malformed mandate: ${shape}`);
|
|
212
|
+
if (signer.publicKeyB64url !== m.issuer) {
|
|
213
|
+
throw new Error('signer public key does not match mandate.issuer — refusing to issue a grant nobody can attribute');
|
|
214
|
+
}
|
|
215
|
+
const cap = assertInt(m.cap, 'cap');
|
|
216
|
+
if (m.perPayment !== undefined && assertInt(m.perPayment, 'perPayment') > cap) {
|
|
217
|
+
throw new Error('perPayment exceeds cap — a per-payment bound above the cumulative cap is incoherent');
|
|
218
|
+
}
|
|
219
|
+
return { mandate: m, alg: 'Ed25519', sig: signer.sign(canonicalBytes(m)) };
|
|
220
|
+
}
|
|
221
|
+
/** Verify the issuer's signature over the (domain-separated) grant, offline. False on any structural or crypto failure. */
|
|
222
|
+
export function verifyMandateSignature(sm) {
|
|
223
|
+
if (!sm || sm.alg !== 'Ed25519' || typeof sm.sig !== 'string')
|
|
224
|
+
return false;
|
|
225
|
+
if (mandateShapeError(sm.mandate))
|
|
226
|
+
return false;
|
|
227
|
+
try {
|
|
228
|
+
return ed25519Verify(canonicalBytes(sm.mandate), sm.mandate.issuer, sm.sig);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The offline authority check for ONE payment against a Mandate. Total —
|
|
236
|
+
* returns a verdict, never throws — and judges expiry on the VERIFIER's clock,
|
|
237
|
+
* not the payer's self-asserted `at`.
|
|
238
|
+
*
|
|
239
|
+
* MULTI-PAYEE (`payment.splits`): a payment may pay several recipients at once
|
|
240
|
+
* (x402 `extra.splits`). EVERY leg is scope-checked, and the per-payment bound
|
|
241
|
+
* and the cap bind the TOTAL across all legs. Without that, a mandate scoped to
|
|
242
|
+
* one merchant would authorize a payment that also pays eight strangers, and a
|
|
243
|
+
* cap would bind only the primary leg while the real outflow exceeded it — the
|
|
244
|
+
* exact failure this object exists to prevent, re-entering through a scheme
|
|
245
|
+
* feature. Absent/empty `splits` is byte-for-byte today's behaviour.
|
|
246
|
+
*
|
|
247
|
+
* HONESTY: this bounds ONE payment's total. The spend LOG still records a
|
|
248
|
+
* single (recipient, amount) per entry, so a multi-leg payment's per-leg
|
|
249
|
+
* breakdown is not represented in the Merkle log — cumulative accounting sees
|
|
250
|
+
* the primary leg only. Log the TOTAL as the entry amount until SpendEntry
|
|
251
|
+
* grows legs, and treat per-leg cumulative attribution as unsolved.
|
|
252
|
+
*/
|
|
253
|
+
export function checkPayment(sm, payment, opts) {
|
|
254
|
+
const reasons = [];
|
|
255
|
+
const warnings = [];
|
|
256
|
+
const shape = mandateShapeError(sm?.mandate);
|
|
257
|
+
if (shape)
|
|
258
|
+
return { ok: false, reasons: [`malformed mandate: ${shape}`], warnings };
|
|
259
|
+
if (!payment || typeof payment !== 'object')
|
|
260
|
+
return { ok: false, reasons: ['payment is not an object'], warnings };
|
|
261
|
+
const m = sm.mandate;
|
|
262
|
+
if (!verifyMandateSignature(sm))
|
|
263
|
+
reasons.push('mandate signature not authentic against issuer');
|
|
264
|
+
let digest;
|
|
265
|
+
try {
|
|
266
|
+
digest = mandateDigest(m);
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
reasons.push(`mandate digest computation failed (unsignable string): ${e.message}`);
|
|
270
|
+
digest = 'invalid';
|
|
271
|
+
}
|
|
272
|
+
if (payment.mandateDigest !== digest)
|
|
273
|
+
reasons.push('payment is not bound to this mandate (digest mismatch)');
|
|
274
|
+
if (payment.payer !== m.subject)
|
|
275
|
+
reasons.push(`payer ${payment.payer} is not the mandate subject ${m.subject}`);
|
|
276
|
+
if (payment.asset !== m.asset)
|
|
277
|
+
reasons.push(`payment asset ${payment.asset} does not match mandate asset ${m.asset}`);
|
|
278
|
+
if (!recipientAllowed(m, payment.recipient))
|
|
279
|
+
reasons.push(`recipient ${payment.recipient} is not in the mandate's allowed set`);
|
|
280
|
+
if (m.recipients.length === 1 && m.recipients[0] === ANY_RECIPIENT)
|
|
281
|
+
warnings.push('mandate opts into an unconstrained recipient set (ANY_RECIPIENT)');
|
|
282
|
+
// Every leg is scope-checked and every leg's amount counts. `splits` is
|
|
283
|
+
// hostile input like any other: a non-array, or a leg with a bad shape,
|
|
284
|
+
// REFUSES rather than being skipped — a silently-ignored leg is money that
|
|
285
|
+
// moved outside the bounds.
|
|
286
|
+
let splitTotal = 0n;
|
|
287
|
+
let splitsUsable = true;
|
|
288
|
+
if (payment.splits !== undefined) {
|
|
289
|
+
if (!Array.isArray(payment.splits)) {
|
|
290
|
+
reasons.push('payment.splits must be an array when present');
|
|
291
|
+
splitsUsable = false;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
for (let i = 0; i < payment.splits.length; i++) {
|
|
295
|
+
const leg = payment.splits[i];
|
|
296
|
+
if (!leg || typeof leg !== 'object') {
|
|
297
|
+
reasons.push(`split leg ${i} is not an object`);
|
|
298
|
+
splitsUsable = false;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (typeof leg.recipient !== 'string') {
|
|
302
|
+
reasons.push(`split leg ${i} recipient must be a string`);
|
|
303
|
+
splitsUsable = false;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!recipientAllowed(m, leg.recipient))
|
|
307
|
+
reasons.push(`split leg ${i} recipient ${leg.recipient} is not in the mandate's allowed set`);
|
|
308
|
+
if (!isInt(leg.amount)) {
|
|
309
|
+
reasons.push(`split leg ${i} amount must be a non-negative integer minor-unit string`);
|
|
310
|
+
splitsUsable = false;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
splitTotal += BigInt(leg.amount);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (!isInt(payment.amount)) {
|
|
318
|
+
reasons.push('payment.amount must be a non-negative integer minor-unit string');
|
|
319
|
+
}
|
|
320
|
+
else if (splitsUsable) {
|
|
321
|
+
// The bound is the TOTAL moved by this payment, not the primary leg.
|
|
322
|
+
const total = BigInt(payment.amount) + splitTotal;
|
|
323
|
+
const label = splitTotal > 0n ? `${total} across ${(payment.splits?.length ?? 0) + 1} legs` : payment.amount;
|
|
324
|
+
if (m.perPayment !== undefined && total > BigInt(m.perPayment))
|
|
325
|
+
reasons.push(`payment ${label} exceeds per-payment bound ${m.perPayment}`);
|
|
326
|
+
if (total > BigInt(m.cap))
|
|
327
|
+
reasons.push(`payment ${label} exceeds the cumulative cap ${m.cap} on its own`);
|
|
328
|
+
}
|
|
329
|
+
if (payment.at !== undefined && parseUtc(payment.at) === null)
|
|
330
|
+
reasons.push('payment.at is not strict RFC 3339 UTC');
|
|
331
|
+
const now = opts?.now ? opts.now() : Date.now();
|
|
332
|
+
if (now >= parseUtc(m.notAfter))
|
|
333
|
+
reasons.push('mandate has expired (verifier clock is at or past notAfter)');
|
|
334
|
+
return { ok: reasons.length === 0, reasons, warnings };
|
|
335
|
+
}
|
|
336
|
+
const spendLeaf = (e) => ({
|
|
337
|
+
drawId: `${e.mandateDigest}|${e.paymentId}`,
|
|
338
|
+
nonce: `${e.recipient}|${e.asset}`,
|
|
339
|
+
seed: `${e.amount}:${e.cumulative}:${e.priorRoot}`,
|
|
340
|
+
});
|
|
341
|
+
/** An append-only, mandate-bound, Merkle-committed record of spend under one mandate. */
|
|
342
|
+
export class SpendLog {
|
|
343
|
+
mandateDigestHex;
|
|
344
|
+
asset;
|
|
345
|
+
entries = [];
|
|
346
|
+
ids = new Set();
|
|
347
|
+
constructor(mandateDigestHex, asset) {
|
|
348
|
+
this.mandateDigestHex = mandateDigestHex;
|
|
349
|
+
this.asset = asset;
|
|
350
|
+
if (!SHA256_PREFIXED.test(mandateDigestHex)) {
|
|
351
|
+
throw new Error('SpendLog mandateDigest must be sha256:<64-hex> (use mandateDigest())');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
add(paymentId, recipient, amount) {
|
|
355
|
+
if (typeof paymentId !== 'string' || !PAYMENT_ID.test(paymentId))
|
|
356
|
+
throw new Error('paymentId must match ^[A-Za-z0-9._~-]{1,64}$ (non-empty, no grammar separators)');
|
|
357
|
+
const amt = assertInt(amount, 'amount');
|
|
358
|
+
if (this.ids.has(paymentId))
|
|
359
|
+
throw new Error(`duplicate paymentId ${paymentId} — a payment cannot be counted twice`);
|
|
360
|
+
const prev = this.entries.length ? assertInt(this.entries[this.entries.length - 1].cumulative, 'cumulative') : 0n;
|
|
361
|
+
this.entries.push({
|
|
362
|
+
mandateDigest: this.mandateDigestHex, asset: this.asset, paymentId, recipient,
|
|
363
|
+
amount, cumulative: (prev + amt).toString(), priorRoot: this.root(),
|
|
364
|
+
});
|
|
365
|
+
this.ids.add(paymentId);
|
|
366
|
+
return this;
|
|
367
|
+
}
|
|
368
|
+
get size() { return this.entries.length; }
|
|
369
|
+
all() { return this.entries.map((e) => ({ ...e })); }
|
|
370
|
+
total() { return this.entries.length ? this.entries[this.entries.length - 1].cumulative : '0'; }
|
|
371
|
+
root() { return transcriptRoot(this.entries.map(spendLeaf)); }
|
|
372
|
+
prove(index) {
|
|
373
|
+
const entry = this.entries[index];
|
|
374
|
+
if (!entry)
|
|
375
|
+
throw new Error(`index ${index} outside spend log of ${this.entries.length}`);
|
|
376
|
+
return { entry, index, size: this.entries.length, proof: inclusionProof(this.entries.map(spendLeaf), index) };
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Produce the committed head over the current (root, total) at a monotonic
|
|
380
|
+
* seq. This is the ONLY blessed committing path: the root and total are
|
|
381
|
+
* derived from the entries the log actually holds, never handed in — an
|
|
382
|
+
* accountant signing a (root, total) it did not derive is signing blind.
|
|
383
|
+
*/
|
|
384
|
+
commit(seq, accountant, at) {
|
|
385
|
+
return commitHead({
|
|
386
|
+
v: COMMIT_VERSION, mandateDigest: this.mandateDigestHex, accountant: accountant.publicKeyB64url,
|
|
387
|
+
seq, root: this.root(), total: this.total(), at,
|
|
388
|
+
}, accountant);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** Verify one spend entry belongs to a committed spend-log root, holding nothing else. */
|
|
392
|
+
export function verifySpendEntry(entry, index, size, proof, root) {
|
|
393
|
+
return verifyInclusion(spendLeaf(entry), index, size, proof, root);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Verify a presented spend history is INTERNALLY sound against a mandate:
|
|
397
|
+
* authenticated grant, every entry bound to THIS mandate + asset, scope and
|
|
398
|
+
* per-payment re-checked, duplicate paymentIds refused, append-only chain
|
|
399
|
+
* verified, running totals gap-free, total within cap.
|
|
400
|
+
*
|
|
401
|
+
* HONESTY: a truncated PREFIX of the true history passes all of this. This
|
|
402
|
+
* function alone is tamper-evidence, not completeness — completeness comes
|
|
403
|
+
* from a committed head (Model A) with a freshness floor.
|
|
404
|
+
*/
|
|
405
|
+
export function verifySpendWithinCap(sm, entries) {
|
|
406
|
+
const reasons = [];
|
|
407
|
+
const warnings = [];
|
|
408
|
+
const shape = mandateShapeError(sm?.mandate);
|
|
409
|
+
if (shape)
|
|
410
|
+
return { ok: false, reasons: [`malformed mandate: ${shape}`], warnings };
|
|
411
|
+
if (!verifyMandateSignature(sm))
|
|
412
|
+
return { ok: false, reasons: ['mandate signature not authentic against issuer'], warnings };
|
|
413
|
+
const m = sm.mandate;
|
|
414
|
+
if (m.recipients.length === 1 && m.recipients[0] === ANY_RECIPIENT)
|
|
415
|
+
warnings.push('mandate opts into an unconstrained recipient set (ANY_RECIPIENT)');
|
|
416
|
+
const digest = mandateDigest(m);
|
|
417
|
+
const cap = BigInt(m.cap);
|
|
418
|
+
const per = m.perPayment !== undefined ? BigInt(m.perPayment) : null;
|
|
419
|
+
let running = 0n;
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
const priorLeaves = [];
|
|
422
|
+
for (let i = 0; i < entries.length; i++) {
|
|
423
|
+
const e = entries[i];
|
|
424
|
+
if (e.mandateDigest !== digest) {
|
|
425
|
+
reasons.push(`entry ${i} is bound to a different mandate`);
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
if (e.asset !== m.asset) {
|
|
429
|
+
reasons.push(`entry ${i} asset ${e.asset} != mandate asset ${m.asset}`);
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
if (seen.has(e.paymentId)) {
|
|
433
|
+
reasons.push(`entry ${i} duplicates paymentId ${e.paymentId} — a payment cannot be counted twice`);
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
seen.add(e.paymentId);
|
|
437
|
+
if (!recipientAllowed(m, e.recipient)) {
|
|
438
|
+
reasons.push(`entry ${i} recipient ${e.recipient} is outside the mandate scope`);
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
if (!isInt(e.amount)) {
|
|
442
|
+
reasons.push(`entry ${i} amount is not an integer minor-unit string`);
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
const amt = BigInt(e.amount);
|
|
446
|
+
if (per !== null && amt > per) {
|
|
447
|
+
reasons.push(`entry ${i} amount ${e.amount} exceeds per-payment bound ${m.perPayment}`);
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
const expectedPrior = priorLeaves.length ? transcriptRoot(priorLeaves) : EMPTY_ROOT;
|
|
451
|
+
if (e.priorRoot !== expectedPrior) {
|
|
452
|
+
reasons.push(`entry ${i} priorRoot breaks the append-only chain (edit/reorder/insert)`);
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
running += amt;
|
|
456
|
+
if (!isInt(e.cumulative) || BigInt(e.cumulative) !== running) {
|
|
457
|
+
reasons.push(`entry ${i} cumulative ${e.cumulative} != running total ${running} — the log is not gap-free`);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
priorLeaves.push(spendLeaf(e));
|
|
461
|
+
}
|
|
462
|
+
if (reasons.length === 0 && running > cap)
|
|
463
|
+
reasons.push(`total spend ${running} exceeds cap ${m.cap}`);
|
|
464
|
+
return { ok: reasons.length === 0, reasons, warnings };
|
|
465
|
+
}
|
|
466
|
+
// ──────────────────── Model A: the committed head ────────────────────
|
|
467
|
+
export const COMMIT_VERSION = 'x402-mandate-commit/1';
|
|
468
|
+
const DS_COMMIT = Buffer.from(COMMIT_VERSION + '\n', 'utf8');
|
|
469
|
+
function commitBytes(c) {
|
|
470
|
+
assertJcsSafe(c);
|
|
471
|
+
return Buffer.concat([DS_COMMIT, Buffer.from(jcsCanonical(c), 'utf8')]);
|
|
472
|
+
}
|
|
473
|
+
/** Sign a committed head. Prefer SpendLog.commit, which derives root/total from real entries. */
|
|
474
|
+
export function commitHead(c, accountant) {
|
|
475
|
+
if (c.v !== COMMIT_VERSION)
|
|
476
|
+
throw new Error(`commitment.v must be ${COMMIT_VERSION}`);
|
|
477
|
+
const uk = c && typeof c === 'object' ? unknownKey(c, COMMIT_KEYS) : null;
|
|
478
|
+
if (uk)
|
|
479
|
+
throw new Error(`commitment carries an unknown member '${uk}' — the object is closed`);
|
|
480
|
+
if (!SHA256_PREFIXED.test(c.mandateDigest))
|
|
481
|
+
throw new Error('commitment.mandateDigest must be sha256:<64-hex>');
|
|
482
|
+
if (!HEX64.test(c.root))
|
|
483
|
+
throw new Error('commitment.root must be 64-hex');
|
|
484
|
+
if (!B64URL_KEY.test(c.accountant))
|
|
485
|
+
throw new Error('commitment.accountant must be a base64url Ed25519 key');
|
|
486
|
+
if (accountant.publicKeyB64url !== c.accountant)
|
|
487
|
+
throw new Error('signer key does not match commitment.accountant');
|
|
488
|
+
if (!Number.isInteger(c.seq) || c.seq < 0 || c.seq > 1e15 - 1)
|
|
489
|
+
throw new Error('commitment.seq must be an integer in [0, 10^15-1]');
|
|
490
|
+
assertInt(c.total, 'commitment.total');
|
|
491
|
+
if (parseUtc(c.at) === null)
|
|
492
|
+
throw new Error('commitment.at must be strict RFC 3339 UTC');
|
|
493
|
+
return { commitment: c, alg: 'Ed25519', sig: accountant.sign(commitBytes(c)) };
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Verify a head is SELF-consistent: signed by the key it names as accountant.
|
|
497
|
+
* Self-consistency is not authorization — verifySpendAgainstCommitment also
|
|
498
|
+
* requires that key to equal the MANDATE's accountant (the trust root).
|
|
499
|
+
*/
|
|
500
|
+
export function verifyCommitment(sc) {
|
|
501
|
+
if (!sc || sc.alg !== 'Ed25519' || typeof sc.sig !== 'string' || sc.commitment?.v !== COMMIT_VERSION)
|
|
502
|
+
return false;
|
|
503
|
+
if (unknownKey(sc.commitment, COMMIT_KEYS))
|
|
504
|
+
return false;
|
|
505
|
+
if (!B64URL_KEY.test(sc.commitment.accountant ?? ''))
|
|
506
|
+
return false;
|
|
507
|
+
try {
|
|
508
|
+
return ed25519Verify(commitBytes(sc.commitment), sc.commitment.accountant, sc.sig);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** LAYER 2: the digest anchored on-chain / in a transparency log. */
|
|
515
|
+
export function commitmentAnchorDigest(sc) {
|
|
516
|
+
return 'sha256:' + createHash('sha256')
|
|
517
|
+
.update(Buffer.concat([commitBytes(sc.commitment), Buffer.from(sc.sig, 'base64url')]))
|
|
518
|
+
.digest('hex');
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* LAYER 2 calldata (the evidence lane's x402note/1 grammar). Carries the
|
|
522
|
+
* mandate + seq so a scanner can find the highest anchored head for a mandate,
|
|
523
|
+
* and `by=<accountant>` so anchors are attributable without fetching the head.
|
|
524
|
+
* Anchoring is permissionless: an anchor proves WHEN, never honesty.
|
|
525
|
+
*/
|
|
526
|
+
export function commitmentAnchorCalldata(sc) {
|
|
527
|
+
if (!SHA256_PREFIXED.test(sc.commitment.mandateDigest))
|
|
528
|
+
throw new Error('commitment.mandateDigest must be sha256:<64-hex>');
|
|
529
|
+
if (!B64URL_KEY.test(sc.commitment.accountant))
|
|
530
|
+
throw new Error('commitment.accountant must be a base64url Ed25519 key');
|
|
531
|
+
return `x402note/1;${commitmentAnchorDigest(sc)};for=${sc.commitment.mandateDigest}#${sc.commitment.seq};by=${sc.commitment.accountant}`;
|
|
532
|
+
}
|
|
533
|
+
/** Verify an on-chain anchor's calldata commits to exactly THIS head. Pair with the tx's block timestamp for the WHEN. */
|
|
534
|
+
export function verifyCommitmentAnchor(sc, calldata) {
|
|
535
|
+
try {
|
|
536
|
+
return calldata === commitmentAnchorCalldata(sc);
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* MODEL A cap check against a committed head. The accountant is taken from the
|
|
544
|
+
* MANDATE (the trust root) — a presenter can never name their own. The
|
|
545
|
+
* presented entries must reproduce the committed root AND total.
|
|
546
|
+
*
|
|
547
|
+
* `lastSeq` is REQUIRED: pass the highest seq you have previously accepted for
|
|
548
|
+
* this mandate (from your own state, or the highest anchored seq you read from
|
|
549
|
+
* Layer 2). Passing `null` means you have no freshness floor — the check still
|
|
550
|
+
* runs, but the verdict carries a loud warning: without a floor you get
|
|
551
|
+
* tamper-evidence, NOT completeness, because an old validly-signed head plus
|
|
552
|
+
* its prefix log passes (first-contact truncation).
|
|
553
|
+
*/
|
|
554
|
+
export function verifySpendAgainstCommitment(sm, entries, sc, lastSeq) {
|
|
555
|
+
const base = verifySpendWithinCap(sm, entries);
|
|
556
|
+
if (!base.ok)
|
|
557
|
+
return base;
|
|
558
|
+
const reasons = [];
|
|
559
|
+
const warnings = [...base.warnings];
|
|
560
|
+
const m = sm.mandate;
|
|
561
|
+
if (m.accountant === PAYEES_ACCOUNTANT) {
|
|
562
|
+
return { ok: false, reasons: ["mandate uses the 'payees' accountant (Model B) — a single-key committed head is not acceptable for it"], warnings };
|
|
563
|
+
}
|
|
564
|
+
if (!sc?.commitment || typeof sc.commitment !== 'object') {
|
|
565
|
+
return { ok: false, reasons: ['committed head is missing or not an object'], warnings };
|
|
566
|
+
}
|
|
567
|
+
if (sc?.commitment?.accountant !== m.accountant) {
|
|
568
|
+
reasons.push('committed head accountant does not match the mandate accountant (the trust root)');
|
|
569
|
+
}
|
|
570
|
+
if (!verifyCommitment(sc))
|
|
571
|
+
reasons.push('committed head signature not self-consistent');
|
|
572
|
+
if (sc.commitment.mandateDigest !== mandateDigest(m))
|
|
573
|
+
reasons.push('committed head is for a different mandate');
|
|
574
|
+
const root = entries.length ? transcriptRoot(entries.map(spendLeaf)) : EMPTY_ROOT;
|
|
575
|
+
if (root !== sc.commitment.root)
|
|
576
|
+
reasons.push('presented spend log does not reproduce the committed head root — omission/truncation');
|
|
577
|
+
const total = entries.length ? entries[entries.length - 1].cumulative : '0';
|
|
578
|
+
if (total !== sc.commitment.total)
|
|
579
|
+
reasons.push(`presented total ${total} != committed total ${sc.commitment.total} — omission/truncation`);
|
|
580
|
+
if (lastSeq === null) {
|
|
581
|
+
warnings.push('no freshness floor (lastSeq=null): this verdict is tamper-evidence only — an old head passes; read the highest anchored seq or track your own');
|
|
582
|
+
}
|
|
583
|
+
else if (sc.commitment.seq <= lastSeq) {
|
|
584
|
+
reasons.push(`committed head seq ${sc.commitment.seq} is not newer than the last accepted ${lastSeq} — rollback`);
|
|
585
|
+
}
|
|
586
|
+
return { ok: reasons.length === 0, reasons, warnings };
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* The counterparty's POST-SETTLE check (MUST-rule 2): the payment it just
|
|
590
|
+
* accepted is INCLUDED under a NEW committed head strictly newer than the one
|
|
591
|
+
* it saw before settling. This is what forces the head to advance per payment
|
|
592
|
+
* — an authorized payment that is never committed is otherwise invisible.
|
|
593
|
+
*/
|
|
594
|
+
export function verifyPaymentCommitted(sm, paymentId, proof, newHead, prevSeq,
|
|
595
|
+
/** OPTIONAL but STRONGLY RECOMMENDED: the fields decoded from the settled
|
|
596
|
+
* artifact. When supplied, the included entry's recipient/asset/amount MUST
|
|
597
|
+
* equal them — this is what stops an agent committing a truthful-looking
|
|
598
|
+
* head over an amount smaller than what actually settled (see §7/§13). */
|
|
599
|
+
settled) {
|
|
600
|
+
const reasons = [];
|
|
601
|
+
const warnings = [];
|
|
602
|
+
const m = sm?.mandate;
|
|
603
|
+
const shape = mandateShapeError(m);
|
|
604
|
+
if (shape)
|
|
605
|
+
return { ok: false, reasons: [`malformed mandate: ${shape}`], warnings };
|
|
606
|
+
if (!newHead?.commitment || typeof newHead.commitment !== 'object') {
|
|
607
|
+
return { ok: false, reasons: ['new committed head is missing or not an object'], warnings };
|
|
608
|
+
}
|
|
609
|
+
if (!proof || typeof proof !== 'object' || !proof.entry) {
|
|
610
|
+
return { ok: false, reasons: ['inclusion proof is missing or malformed'], warnings };
|
|
611
|
+
}
|
|
612
|
+
if (!verifyMandateSignature(sm))
|
|
613
|
+
reasons.push('mandate signature not authentic against issuer');
|
|
614
|
+
if (m.accountant === PAYEES_ACCOUNTANT)
|
|
615
|
+
reasons.push("mandate uses the 'payees' accountant — Model A inclusion check does not apply");
|
|
616
|
+
else if (newHead?.commitment?.accountant !== m.accountant)
|
|
617
|
+
reasons.push('new head accountant does not match the mandate accountant');
|
|
618
|
+
if (!verifyCommitment(newHead))
|
|
619
|
+
reasons.push('new head signature not self-consistent');
|
|
620
|
+
if (newHead.commitment.mandateDigest !== mandateDigest(m))
|
|
621
|
+
reasons.push('new head is for a different mandate');
|
|
622
|
+
if (!(Number.isInteger(prevSeq)) || newHead.commitment.seq <= prevSeq) {
|
|
623
|
+
reasons.push(`new head seq ${newHead.commitment.seq} is not newer than the pre-settle seq ${prevSeq}`);
|
|
624
|
+
}
|
|
625
|
+
if (proof.entry.paymentId !== paymentId)
|
|
626
|
+
reasons.push(`proof is for payment ${proof.entry.paymentId}, expected ${paymentId}`);
|
|
627
|
+
if (!verifySpendEntry(proof.entry, proof.index, proof.size, proof.proof, newHead.commitment.root)) {
|
|
628
|
+
reasons.push('payment is NOT included under the new committed head');
|
|
629
|
+
}
|
|
630
|
+
if (settled) {
|
|
631
|
+
const e = proof.entry;
|
|
632
|
+
if (e.recipient !== settled.recipient)
|
|
633
|
+
reasons.push(`committed entry recipient ${e.recipient} != settled recipient ${settled.recipient}`);
|
|
634
|
+
if (e.asset !== settled.asset)
|
|
635
|
+
reasons.push(`committed entry asset ${e.asset} != settled asset ${settled.asset}`);
|
|
636
|
+
if (e.amount !== settled.amount)
|
|
637
|
+
reasons.push(`committed entry amount ${e.amount} != settled amount ${settled.amount} — under-reported spend`);
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
warnings.push('no settled fields supplied: entry amount/recipient/asset are agent-asserted, not verified against the rail');
|
|
641
|
+
}
|
|
642
|
+
return { ok: reasons.length === 0, reasons, warnings };
|
|
643
|
+
}
|
|
644
|
+
// ──────────────────── Model B: aggregated payee attestations ────────────────────
|
|
645
|
+
export const PAYEE_ATT_VERSION = 'x402-mandate-payee/1';
|
|
646
|
+
const DS_PAYEE = Buffer.from(PAYEE_ATT_VERSION + '\n', 'utf8');
|
|
647
|
+
function payeeBytes(a) {
|
|
648
|
+
assertJcsSafe(a);
|
|
649
|
+
return Buffer.concat([DS_PAYEE, Buffer.from(jcsCanonical(a), 'utf8')]);
|
|
650
|
+
}
|
|
651
|
+
/** The payee signs "I received this payment under this mandate." Signer's key must BE the payee identity. */
|
|
652
|
+
export function attestPayment(a, payee) {
|
|
653
|
+
if (a.v !== PAYEE_ATT_VERSION)
|
|
654
|
+
throw new Error(`attestation.v must be ${PAYEE_ATT_VERSION}`);
|
|
655
|
+
const uk = a && typeof a === 'object' ? unknownKey(a, ATT_KEYS) : null;
|
|
656
|
+
if (uk)
|
|
657
|
+
throw new Error(`attestation carries an unknown member '${uk}' — the object is closed`);
|
|
658
|
+
if (!B64URL_KEY.test(a.payee))
|
|
659
|
+
throw new Error('attestation.payee must be a base64url Ed25519 key (the recipient identity IS the key)');
|
|
660
|
+
if (payee.publicKeyB64url !== a.payee)
|
|
661
|
+
throw new Error('signer key does not match attestation.payee');
|
|
662
|
+
if (!SHA256_PREFIXED.test(a.mandateDigest))
|
|
663
|
+
throw new Error('attestation.mandateDigest must be sha256:<64-hex>');
|
|
664
|
+
assertInt(a.amount, 'attestation.amount');
|
|
665
|
+
if (parseUtc(a.at) === null)
|
|
666
|
+
throw new Error('attestation.at must be strict RFC 3339 UTC');
|
|
667
|
+
return { attestation: a, alg: 'Ed25519', sig: payee.sign(payeeBytes(a)) };
|
|
668
|
+
}
|
|
669
|
+
/** Verify a payee attestation's signature against the payee identity itself, offline. */
|
|
670
|
+
export function verifyPayeeAttestation(spa) {
|
|
671
|
+
if (!spa || spa.alg !== 'Ed25519' || typeof spa.sig !== 'string' || spa.attestation?.v !== PAYEE_ATT_VERSION)
|
|
672
|
+
return false;
|
|
673
|
+
if (unknownKey(spa.attestation, ATT_KEYS))
|
|
674
|
+
return false;
|
|
675
|
+
if (!B64URL_KEY.test(spa.attestation.payee ?? ''))
|
|
676
|
+
return false;
|
|
677
|
+
try {
|
|
678
|
+
return ed25519Verify(payeeBytes(spa.attestation), spa.attestation.payee, spa.sig);
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* MODEL B check: every presented entry must be backed by an authentic
|
|
686
|
+
* attestation signed by the RECIPIENT ITSELF (identity == key). Requires the
|
|
687
|
+
* mandate's accountant to be 'payees'.
|
|
688
|
+
*
|
|
689
|
+
* WHAT THIS PROVES: no fabricated entries — an agent cannot invent a payment a
|
|
690
|
+
* payee never acknowledged.
|
|
691
|
+
* WHAT IT DOES NOT PROVE: completeness. Dropping an entry AND its attestation
|
|
692
|
+
* passes. Do not present this check alone as omission-proof — completeness in
|
|
693
|
+
* Model B comes from reconcilePayeeEvidence below, run against payee evidence
|
|
694
|
+
* the verifier gathered independently of the agent.
|
|
695
|
+
*/
|
|
696
|
+
export function verifySpendWithPayeeAttestations(sm, entries, attestations) {
|
|
697
|
+
const base = verifySpendWithinCap(sm, entries);
|
|
698
|
+
if (!base.ok)
|
|
699
|
+
return base;
|
|
700
|
+
const reasons = [];
|
|
701
|
+
const warnings = [...base.warnings];
|
|
702
|
+
if (sm.mandate.accountant !== PAYEES_ACCOUNTANT) {
|
|
703
|
+
return { ok: false, reasons: ["mandate does not use the 'payees' accountant — use the committed-head path (Model A)"], warnings };
|
|
704
|
+
}
|
|
705
|
+
const digest = mandateDigest(sm.mandate);
|
|
706
|
+
// Keyed by paymentId|recipient so the RIGHT payee's attestation backs each
|
|
707
|
+
// entry even when two payees reference one paymentId.
|
|
708
|
+
const byId = new Map(attestations.map((a) => [`${a.attestation.paymentId}|${a.attestation.payee}`, a]));
|
|
709
|
+
for (let i = 0; i < entries.length; i++) {
|
|
710
|
+
const e = entries[i];
|
|
711
|
+
const spa = byId.get(`${e.paymentId}|${e.recipient}`);
|
|
712
|
+
if (!spa) {
|
|
713
|
+
reasons.push(`entry ${i} (payment ${e.paymentId}) has no payee attestation from its recipient — an unbacked agent claim`);
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
const a = spa.attestation;
|
|
717
|
+
if (a.payee !== e.recipient) {
|
|
718
|
+
reasons.push(`entry ${i} attestation payee ${a.payee} != entry recipient ${e.recipient}`);
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
if (!verifyPayeeAttestation(spa)) {
|
|
722
|
+
reasons.push(`entry ${i} payee attestation is not authentic (must be signed by the recipient's own key)`);
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
if (a.mandateDigest !== digest) {
|
|
726
|
+
reasons.push(`entry ${i} payee attestation is for a different mandate`);
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
if (a.asset !== e.asset || a.amount !== e.amount) {
|
|
730
|
+
reasons.push(`entry ${i} attestation amount/asset disagrees with the entry`);
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
if (parseUtc(a.at) === null) {
|
|
734
|
+
reasons.push(`entry ${i} attestation.at is not strict RFC 3339 UTC`);
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return { ok: reasons.length === 0, reasons, warnings };
|
|
739
|
+
}
|
|
740
|
+
/** The digest a payee attestation is anchored under. */
|
|
741
|
+
export function payeeAttestationAnchorDigest(spa) {
|
|
742
|
+
return 'sha256:' + createHash('sha256')
|
|
743
|
+
.update(Buffer.concat([payeeBytes(spa.attestation), Buffer.from(spa.sig, 'base64url')]))
|
|
744
|
+
.digest('hex');
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* A payee anchors ITS OWN evidence (the x402note/1 grammar; `@<paymentId>`
|
|
748
|
+
* keeps attestation anchors distinguishable from head anchors' `#<seq>`). This
|
|
749
|
+
* is what makes an omitted payment publicly discoverable: the agent controls
|
|
750
|
+
* its log, but it does not control the payee's anchor.
|
|
751
|
+
*/
|
|
752
|
+
export function payeeAttestationAnchorCalldata(spa) {
|
|
753
|
+
const a = spa.attestation;
|
|
754
|
+
if (!SHA256_PREFIXED.test(a.mandateDigest))
|
|
755
|
+
throw new Error('attestation.mandateDigest must be sha256:<64-hex>');
|
|
756
|
+
if (!B64URL_KEY.test(a.payee))
|
|
757
|
+
throw new Error('attestation.payee must be a base64url Ed25519 key');
|
|
758
|
+
return `x402note/1;${payeeAttestationAnchorDigest(spa)};for=${a.mandateDigest}@${a.paymentId};by=${a.payee}`;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* MODEL B OMISSION-RESISTANCE — the payee-evidence reconciler. `evidence` is
|
|
762
|
+
* the set of payee attestations the VERIFIER gathered INDEPENDENTLY (from the
|
|
763
|
+
* payees, or by scanning their anchors) — never from the agent. Then:
|
|
764
|
+
*
|
|
765
|
+
* 1. the presented entries must all be payee-backed (no fabrication), AND
|
|
766
|
+
* 2. every valid attestation for THIS mandate must appear in the log — an
|
|
767
|
+
* attested payment with no entry is the OMISSION, caught by the party who
|
|
768
|
+
* cannot un-know it was paid, AND
|
|
769
|
+
* 3. the attested floor (sum over all attested payments, whether logged or
|
|
770
|
+
* not) must be within cap — a short log cannot hide over-spend the payees
|
|
771
|
+
* can prove.
|
|
772
|
+
*
|
|
773
|
+
* Hostile-input hygiene: attestations that fail signature verification or
|
|
774
|
+
* belong to other mandates are IGNORED with a warning (a stranger cannot DoS a
|
|
775
|
+
* verdict with junk); two authentic attestations from one payee for one
|
|
776
|
+
* paymentId with different facts = payee equivocation, refused loudly.
|
|
777
|
+
*/
|
|
778
|
+
export function reconcilePayeeEvidence(sm, entries, evidence) {
|
|
779
|
+
const reasons = [];
|
|
780
|
+
const warnings = [];
|
|
781
|
+
const shape = mandateShapeError(sm?.mandate);
|
|
782
|
+
if (shape)
|
|
783
|
+
return { ok: false, reasons: [`malformed mandate: ${shape}`], warnings };
|
|
784
|
+
const digest = mandateDigest(sm.mandate);
|
|
785
|
+
// 1. ADMIT the evidence set first: only authentic, this-mandate, IN-SCOPE
|
|
786
|
+
// attestations count; junk and out-of-scope keys are noise, not veto (a
|
|
787
|
+
// stranger cannot force a refusal); equivocation is same-payee only.
|
|
788
|
+
// Keyed by `${payee}|${paymentId}` so a second payee reusing a known
|
|
789
|
+
// paymentId is not mistaken for the first payee equivocating.
|
|
790
|
+
const scoped = !(sm.mandate.recipients.length === 1 && sm.mandate.recipients[0] === ANY_RECIPIENT);
|
|
791
|
+
const admitted = new Map();
|
|
792
|
+
for (const spa of evidence) {
|
|
793
|
+
if (!verifyPayeeAttestation(spa)) {
|
|
794
|
+
warnings.push(`ignored an attestation with an invalid signature (payment ${spa?.attestation?.paymentId ?? '?'})`);
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
const a = spa.attestation;
|
|
798
|
+
if (a.mandateDigest !== digest) {
|
|
799
|
+
warnings.push(`ignored an attestation for a different mandate (payment ${a.paymentId})`);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (scoped && !sm.mandate.recipients.includes(a.payee)) {
|
|
803
|
+
warnings.push(`ignored an attestation from a payee outside the mandate's recipient scope (payment ${a.paymentId})`);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const key = `${a.payee}|${a.paymentId}`;
|
|
807
|
+
const prior = admitted.get(key);
|
|
808
|
+
if (prior) {
|
|
809
|
+
const p = prior.attestation;
|
|
810
|
+
if (p.amount !== a.amount || p.asset !== a.asset) {
|
|
811
|
+
reasons.push(`payee equivocation on payment ${a.paymentId}: same payee, two authentic attestations with different facts`);
|
|
812
|
+
}
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
admitted.set(key, spa);
|
|
816
|
+
}
|
|
817
|
+
if (reasons.length)
|
|
818
|
+
return { ok: false, reasons, warnings };
|
|
819
|
+
// 2. The presented entries must all be backed by the ADMITTED evidence.
|
|
820
|
+
const base = verifySpendWithPayeeAttestations(sm, entries, [...admitted.values()]);
|
|
821
|
+
if (!base.ok)
|
|
822
|
+
return { ok: false, reasons: base.reasons, warnings: [...warnings, ...base.warnings] };
|
|
823
|
+
warnings.push(...base.warnings);
|
|
824
|
+
// 3. COMPLETENESS: every attested payment must be in the presented log.
|
|
825
|
+
const logged = new Set(entries.map((e) => e.paymentId));
|
|
826
|
+
let attestedFloor = 0n;
|
|
827
|
+
for (const spa of admitted.values()) {
|
|
828
|
+
const paymentId = spa.attestation.paymentId;
|
|
829
|
+
attestedFloor += BigInt(spa.attestation.amount);
|
|
830
|
+
if (!logged.has(paymentId)) {
|
|
831
|
+
reasons.push(`OMISSION: payee ${spa.attestation.payee.slice(0, 12)}… attests payment ${paymentId} (${spa.attestation.amount} ${spa.attestation.asset}) absent from the presented log`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// 4. The attested floor binds the cap even when the log is short.
|
|
835
|
+
if (attestedFloor > BigInt(sm.mandate.cap)) {
|
|
836
|
+
reasons.push(`attested spend floor ${attestedFloor} exceeds cap ${sm.mandate.cap} — over-spend provable from payee evidence alone`);
|
|
837
|
+
}
|
|
838
|
+
return { ok: reasons.length === 0, reasons, warnings };
|
|
839
|
+
}
|
|
840
|
+
// ──────────────────── delegation chains: the human origin survives hops ────────────────────
|
|
841
|
+
//
|
|
842
|
+
// A parent's SUBJECT (which must itself be an Ed25519 key for the grant to be
|
|
843
|
+
// delegable) can carve a CHILD grant that only NARROWS: same asset, same
|
|
844
|
+
// accountant, cap and per-payment no larger, expiry no later, recipients a
|
|
845
|
+
// subset (ANY only under ANY). verifyDelegationChain walks root→leaf entirely
|
|
846
|
+
// offline and returns the ROOT ISSUER — the human origin that #3086-style
|
|
847
|
+
// multi-hop delegation otherwise dissolves.
|
|
848
|
+
//
|
|
849
|
+
// HONESTY: narrowing is per-link and static. Nothing here prevents a parent
|
|
850
|
+
// subject from issuing SIBLING children whose caps SUM above the parent cap —
|
|
851
|
+
// aggregate delegation accounting is the accountant's job across the tree and
|
|
852
|
+
// is deliberately not faked statically.
|
|
853
|
+
const effPerPayment = (m) => BigInt(m.perPayment ?? m.cap);
|
|
854
|
+
/** One narrowing violation, or null. Exported logic via issueDelegatedMandate/verifyDelegationChain. */
|
|
855
|
+
function narrowingError(child, parent) {
|
|
856
|
+
if (child.parent !== mandateDigest(parent))
|
|
857
|
+
return 'child.parent does not reference the parent mandate digest';
|
|
858
|
+
if (!B64URL_KEY.test(parent.subject))
|
|
859
|
+
return 'parent.subject is not an Ed25519 key — this grant is not delegable';
|
|
860
|
+
if (child.issuer !== parent.subject)
|
|
861
|
+
return 'child.issuer must be the parent subject (the delegator)';
|
|
862
|
+
if (child.asset !== parent.asset)
|
|
863
|
+
return 'child asset differs from parent';
|
|
864
|
+
if (child.accountant !== parent.accountant)
|
|
865
|
+
return 'child accountant differs from parent — one accounting domain per chain';
|
|
866
|
+
if (BigInt(child.cap) > BigInt(parent.cap))
|
|
867
|
+
return `child cap ${child.cap} exceeds parent cap ${parent.cap}`;
|
|
868
|
+
if (effPerPayment(child) > effPerPayment(parent))
|
|
869
|
+
return 'child per-payment bound exceeds the parent\'s';
|
|
870
|
+
if (parseUtc(child.notAfter) > parseUtc(parent.notAfter))
|
|
871
|
+
return 'child expiry is later than the parent\'s';
|
|
872
|
+
const parentAny = parent.recipients.length === 1 && parent.recipients[0] === ANY_RECIPIENT;
|
|
873
|
+
const childAny = child.recipients.length === 1 && child.recipients[0] === ANY_RECIPIENT;
|
|
874
|
+
if (!parentAny) {
|
|
875
|
+
if (childAny)
|
|
876
|
+
return 'child cannot opt into ANY_RECIPIENT under a scoped parent';
|
|
877
|
+
for (const r of child.recipients) {
|
|
878
|
+
if (!parent.recipients.includes(r))
|
|
879
|
+
return `child recipient ${r} is outside the parent scope`;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
/** The parent's subject carves a narrowed child grant. Refuses any widening. */
|
|
885
|
+
export function issueDelegatedMandate(child, parentSm, delegator) {
|
|
886
|
+
if (!verifyMandateSignature(parentSm))
|
|
887
|
+
throw new Error('parent mandate signature not authentic — refusing to delegate from it');
|
|
888
|
+
const shape = mandateShapeError(child);
|
|
889
|
+
if (shape)
|
|
890
|
+
throw new Error(`refusing to issue a malformed child mandate: ${shape}`);
|
|
891
|
+
const narrow = narrowingError(child, parentSm.mandate);
|
|
892
|
+
if (narrow)
|
|
893
|
+
throw new Error(`refusing a widening delegation: ${narrow}`);
|
|
894
|
+
return issueMandate(child, delegator);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Walk a delegation chain root→leaf, entirely offline: every link signed,
|
|
898
|
+
* every hop narrowing, the leaf usable exactly like any mandate. Returns the
|
|
899
|
+
* ROOT ISSUER — the human origin, recoverable at any depth.
|
|
900
|
+
*/
|
|
901
|
+
export function verifyDelegationChain(chain) {
|
|
902
|
+
const reasons = [];
|
|
903
|
+
const warnings = [];
|
|
904
|
+
if (!Array.isArray(chain) || chain.length === 0) {
|
|
905
|
+
return { ok: false, reasons: ['delegation chain is empty'], warnings };
|
|
906
|
+
}
|
|
907
|
+
const root = chain[0];
|
|
908
|
+
if (root.mandate?.parent !== undefined)
|
|
909
|
+
reasons.push('chain[0] must be a ROOT grant (no parent)');
|
|
910
|
+
for (let i = 0; i < chain.length; i++) {
|
|
911
|
+
const link = chain[i];
|
|
912
|
+
const shape = mandateShapeError(link?.mandate);
|
|
913
|
+
if (shape) {
|
|
914
|
+
reasons.push(`chain[${i}] malformed: ${shape}`);
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
if (!verifyMandateSignature(link)) {
|
|
918
|
+
reasons.push(`chain[${i}] signature not authentic`);
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
if (i > 0) {
|
|
922
|
+
const narrow = narrowingError(link.mandate, chain[i - 1].mandate);
|
|
923
|
+
if (narrow) {
|
|
924
|
+
reasons.push(`chain[${i}] widens its parent: ${narrow}`);
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (reasons.length)
|
|
930
|
+
return { ok: false, reasons, warnings };
|
|
931
|
+
return { ok: true, reasons, warnings, rootIssuer: root.mandate.issuer };
|
|
932
|
+
}
|
|
933
|
+
// ──────────────────── the preimage binding: enforced, not asserted ────────────────────
|
|
934
|
+
//
|
|
935
|
+
// A MandatePayment's mandateDigest field is only a claim until the SETTLED
|
|
936
|
+
// artifact commits it. Each scheme has exactly one payer-chosen 32-byte slot
|
|
937
|
+
// that its signature covers:
|
|
938
|
+
//
|
|
939
|
+
// EIP-3009 nonce (bytes32) — signed in TransferWithAuthorization;
|
|
940
|
+
// the token contract refuses a reused nonce.
|
|
941
|
+
// Permit2 nonce (uint256) — signed in PermitTransferFrom; unordered
|
|
942
|
+
// nonces are consumed once.
|
|
943
|
+
// XRPL InvoiceID (256-bit) — carried in the signed Payment tx (our
|
|
944
|
+
// lane forbids Memos; InvoiceID is the slot).
|
|
945
|
+
//
|
|
946
|
+
// The binding rule: that slot MUST equal sha256(DS || mandateDigest || '\n' ||
|
|
947
|
+
// paymentId). A verifier holding the settled artifact re-derives and compares —
|
|
948
|
+
// no trust in any presented field. Determinism gives at-most-once ONLY on the
|
|
949
|
+
// two rails that enforce slot uniqueness: EIP-3009 and Permit2 consume the nonce,
|
|
950
|
+
// so the SAME (mandate, paymentId) settles at most once there. XRPL does NOT
|
|
951
|
+
// enforce InvoiceID uniqueness — two Payments with the same InvoiceID can both
|
|
952
|
+
// settle. Any consumer deriving cumulative-spend conclusions from XRPL evidence
|
|
953
|
+
// MUST de-duplicate observed settlements by (mandateDigest, paymentId) and treat
|
|
954
|
+
// two distinct validated Payments with one derived InvoiceID as spend exceeding
|
|
955
|
+
// the log — the rail will not enforce it.
|
|
956
|
+
//
|
|
957
|
+
// The slot binds only (mandate, paymentId): it does NOT constrain the
|
|
958
|
+
// transferred amount/recipient/asset. verifySettledPayment reads those OUT of
|
|
959
|
+
// the artifact and runs the §6 authority check on them — call it, not
|
|
960
|
+
// verifyPaymentBinding alone, whenever a cap must actually bind.
|
|
961
|
+
export const BINDING_VERSION = 'x402-mandate-binding/1';
|
|
962
|
+
const DS_BIND = Buffer.from(BINDING_VERSION + '\n', 'utf8');
|
|
963
|
+
function bindingHash(mandateDigestStr, paymentId) {
|
|
964
|
+
if (!SHA256_PREFIXED.test(mandateDigestStr))
|
|
965
|
+
throw new Error('mandateDigest must be sha256:<64-hex>');
|
|
966
|
+
if (typeof paymentId !== 'string' || !PAYMENT_ID.test(paymentId))
|
|
967
|
+
throw new Error('paymentId must match ^[A-Za-z0-9._~-]{1,64}$ (non-empty, no grammar separators)');
|
|
968
|
+
return createHash('sha256')
|
|
969
|
+
.update(Buffer.concat([DS_BIND, Buffer.from(mandateDigestStr + '\n' + paymentId, 'utf8')]))
|
|
970
|
+
.digest();
|
|
971
|
+
}
|
|
972
|
+
/** The bytes32 nonce an EIP-3009 TransferWithAuthorization MUST carry for this (mandate, payment). */
|
|
973
|
+
export function eip3009BindingNonce(mandateDigestStr, paymentId) {
|
|
974
|
+
return '0x' + bindingHash(mandateDigestStr, paymentId).toString('hex');
|
|
975
|
+
}
|
|
976
|
+
/** The uint256 nonce (decimal string) a Permit2 PermitTransferFrom MUST carry. */
|
|
977
|
+
export function permit2BindingNonce(mandateDigestStr, paymentId) {
|
|
978
|
+
return BigInt('0x' + bindingHash(mandateDigestStr, paymentId).toString('hex')).toString();
|
|
979
|
+
}
|
|
980
|
+
/** The InvoiceID (uppercase 64-hex, XRPL convention) a Payment tx MUST carry. */
|
|
981
|
+
export function xrplBindingInvoiceId(mandateDigestStr, paymentId) {
|
|
982
|
+
return bindingHash(mandateDigestStr, paymentId).toString('hex').toUpperCase();
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Verify the binding FROM THE SETTLED ARTIFACT: pass the value read out of the
|
|
986
|
+
* settled authorization/tx (the EIP-3009 nonce, the Permit2 nonce, the XRPL
|
|
987
|
+
* InvoiceID) and the claimed (mandate, paymentId). True iff the slot commits to
|
|
988
|
+
* exactly this pair. Total — false on anything malformed.
|
|
989
|
+
*/
|
|
990
|
+
export function verifyPaymentBinding(scheme, settledValue, mandateDigestStr, paymentId) {
|
|
991
|
+
try {
|
|
992
|
+
const h = bindingHash(mandateDigestStr, paymentId);
|
|
993
|
+
if (typeof settledValue !== 'string')
|
|
994
|
+
return false;
|
|
995
|
+
switch (scheme) {
|
|
996
|
+
case 'eip3009': return settledValue.toLowerCase() === '0x' + h.toString('hex');
|
|
997
|
+
case 'permit2': return /^(0|[1-9][0-9]*)$/.test(settledValue) && BigInt(settledValue) === BigInt('0x' + h.toString('hex'));
|
|
998
|
+
case 'xrpl': return /^[0-9A-Fa-f]{64}$/.test(settledValue) && settledValue.toUpperCase() === h.toString('hex').toUpperCase();
|
|
999
|
+
default: return false;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* THE ENFORCEMENT the §7 binding alone cannot give: the binding slot commits
|
|
1008
|
+
* only to (mandate, paymentId), NOT to amount/recipient/asset. A cap that binds
|
|
1009
|
+
* only self-asserted amounts binds nothing on a rail the agent controls. This
|
|
1010
|
+
* check reads the ACTUAL transferred fields out of the settled artifact and
|
|
1011
|
+
* runs the full §6 authority check on THEM — so an agent that settles 1,000,000
|
|
1012
|
+
* cannot log amount='1'. A counterparty deriving cumulative-spend conclusions
|
|
1013
|
+
* MUST call this, not verifyPaymentBinding alone. Total — never throws.
|
|
1014
|
+
*/
|
|
1015
|
+
export function verifySettledPayment(sm, settled, paymentId, opts) {
|
|
1016
|
+
const shape = mandateShapeError(sm?.mandate);
|
|
1017
|
+
if (shape)
|
|
1018
|
+
return { ok: false, reasons: [`malformed mandate: ${shape}`], warnings: [] };
|
|
1019
|
+
if (!settled || typeof settled !== 'object')
|
|
1020
|
+
return { ok: false, reasons: ['settled transfer is missing or not an object'], warnings: [] };
|
|
1021
|
+
let digest;
|
|
1022
|
+
try {
|
|
1023
|
+
digest = mandateDigest(sm.mandate);
|
|
1024
|
+
}
|
|
1025
|
+
catch (e) {
|
|
1026
|
+
return { ok: false, reasons: [`mandate digest failed: ${e.message}`], warnings: [] };
|
|
1027
|
+
}
|
|
1028
|
+
const reasons = [];
|
|
1029
|
+
if (!verifyPaymentBinding(settled.scheme, settled.slot, digest, paymentId)) {
|
|
1030
|
+
reasons.push('settled slot does not bind (mandate, paymentId) — binding mismatch');
|
|
1031
|
+
}
|
|
1032
|
+
// Run §6 on the fields DECODED from the artifact, not any presented tuple —
|
|
1033
|
+
// including EVERY leg of a multi-payee split, so the cap binds the whole
|
|
1034
|
+
// outflow rather than the primary transfer.
|
|
1035
|
+
const decoded = {
|
|
1036
|
+
payer: settled.payer, recipient: settled.recipient, asset: settled.asset,
|
|
1037
|
+
amount: settled.amount, mandateDigest: digest,
|
|
1038
|
+
...(settled.splits !== undefined ? { splits: settled.splits } : {}),
|
|
1039
|
+
};
|
|
1040
|
+
const v = checkPayment(sm, decoded, opts);
|
|
1041
|
+
return { ok: reasons.length === 0 && v.ok, reasons: [...reasons, ...v.reasons], warnings: v.warnings };
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Parse an on-chain head-anchor calldata. Tolerates the historic pre-`by=`
|
|
1045
|
+
* grammar so scanners can read every anchor ever made. Returns null on
|
|
1046
|
+
* anything that is not a head anchor (payee anchors use `@`, not `#`).
|
|
1047
|
+
*/
|
|
1048
|
+
export function parseCommitmentAnchorCalldata(calldata) {
|
|
1049
|
+
if (typeof calldata !== 'string')
|
|
1050
|
+
return null;
|
|
1051
|
+
const m = /^x402note\/1;(sha256:[0-9a-f]{64});for=(sha256:[0-9a-f]{64})#(\d{1,15})(?:;by=([A-Za-z0-9_-]{43}))?$/.exec(calldata);
|
|
1052
|
+
if (!m)
|
|
1053
|
+
return null;
|
|
1054
|
+
const seq = Number(m[3]);
|
|
1055
|
+
if (!Number.isSafeInteger(seq))
|
|
1056
|
+
return null;
|
|
1057
|
+
return { anchorDigest: m[1], mandateDigest: m[2], seq, ...(m[4] ? { by: m[4] } : {}) };
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* RUNG 3, done properly: derive a freshness floor ONLY from anchors whose
|
|
1061
|
+
* content has been retrieved and re-verified.
|
|
1062
|
+
*
|
|
1063
|
+
* WHY THIS EXISTS: anchoring is permissionless and `by=` is unauthenticated
|
|
1064
|
+
* plaintext, while the accountant's key is public (it is named in the grant).
|
|
1065
|
+
* So `highestAnchoredSeq` — which trusts the seq printed in calldata — is
|
|
1066
|
+
* POISONABLE: a stranger anchors `…#999999999;by=<the real accountant>` and
|
|
1067
|
+
* every honest head afterwards is refused as a rollback. That failure is not a
|
|
1068
|
+
* missed omission, it is an honest record permanently refused.
|
|
1069
|
+
*
|
|
1070
|
+
* Here an anchored seq is a POINTER, never a fact. For an anchor to move the
|
|
1071
|
+
* floor, all of the following must hold: the calldata parses and names THIS
|
|
1072
|
+
* mandate; `by=` equals the mandate's accountant; the head was retrieved; the
|
|
1073
|
+
* anchor digest recomputes over that head; the head's signature verifies; and
|
|
1074
|
+
* the head itself names this mandate, this accountant, and this seq. Anything
|
|
1075
|
+
* short of that is ignored with a warning — which can only LOWER the floor
|
|
1076
|
+
* toward the tamper-evidence downgrade, never raise it.
|
|
1077
|
+
*/
|
|
1078
|
+
export function verifiedAnchoredSeq(sm, anchors) {
|
|
1079
|
+
const warnings = [];
|
|
1080
|
+
const shape = mandateShapeError(sm?.mandate);
|
|
1081
|
+
if (shape)
|
|
1082
|
+
return { seq: null, reasons: [`malformed mandate: ${shape}`], warnings };
|
|
1083
|
+
const m = sm.mandate;
|
|
1084
|
+
if (m.accountant === PAYEES_ACCOUNTANT) {
|
|
1085
|
+
return { seq: null, reasons: ["mandate uses the 'payees' accountant (Model B) — no committed heads to anchor"], warnings };
|
|
1086
|
+
}
|
|
1087
|
+
let digest;
|
|
1088
|
+
try {
|
|
1089
|
+
digest = mandateDigest(m);
|
|
1090
|
+
}
|
|
1091
|
+
catch (e) {
|
|
1092
|
+
return { seq: null, reasons: [`mandate digest failed: ${e.message}`], warnings };
|
|
1093
|
+
}
|
|
1094
|
+
let best = null;
|
|
1095
|
+
for (const a of Array.isArray(anchors) ? anchors : []) {
|
|
1096
|
+
const p = a && typeof a === 'object' ? parseCommitmentAnchorCalldata(a.calldata) : null;
|
|
1097
|
+
if (!p) {
|
|
1098
|
+
warnings.push('ignored an anchor whose calldata does not parse');
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
if (p.mandateDigest !== digest)
|
|
1102
|
+
continue; // another mandate's anchor: not noise, just not ours
|
|
1103
|
+
if (p.by === undefined) {
|
|
1104
|
+
warnings.push(`ignored an anchor with no by= segment at seq ${p.seq} (historic grammar is not floor-eligible)`);
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
if (p.by !== m.accountant) {
|
|
1108
|
+
warnings.push(`ignored an anchor naming a key that is not the mandate's accountant at seq ${p.seq}`);
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
// by= is only a routing hint — everything below is the actual check.
|
|
1112
|
+
if (!a.head) {
|
|
1113
|
+
warnings.push(`ignored an anchor at seq ${p.seq}: content not retrieved, so its seq is unverified`);
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
let recomputed;
|
|
1117
|
+
try {
|
|
1118
|
+
recomputed = commitmentAnchorDigest(a.head);
|
|
1119
|
+
}
|
|
1120
|
+
catch {
|
|
1121
|
+
warnings.push(`ignored an anchor at seq ${p.seq}: head could not be digested`);
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (recomputed !== p.anchorDigest) {
|
|
1125
|
+
warnings.push(`ignored an anchor at seq ${p.seq}: retrieved head does not reproduce the anchored digest`);
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
if (!verifyCommitment(a.head)) {
|
|
1129
|
+
warnings.push(`ignored an anchor at seq ${p.seq}: head signature is not self-consistent`);
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const c = a.head.commitment;
|
|
1133
|
+
if (c.accountant !== m.accountant) {
|
|
1134
|
+
warnings.push(`ignored an anchor at seq ${p.seq}: head accountant is not the mandate's`);
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
if (c.mandateDigest !== digest) {
|
|
1138
|
+
warnings.push(`ignored an anchor at seq ${p.seq}: head is for a different mandate`);
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
if (c.seq !== p.seq) {
|
|
1142
|
+
warnings.push(`ignored an anchor claiming seq ${p.seq} over a head at seq ${c.seq}`);
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
if (best === null || c.seq > best)
|
|
1146
|
+
best = c.seq;
|
|
1147
|
+
}
|
|
1148
|
+
return { seq: best, reasons: [], warnings };
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* ⚠️ UNVERIFIED floor: trusts the seq printed in permissionless calldata and is
|
|
1152
|
+
* therefore POISONABLE by any chain writer (see verifiedAnchoredSeq). Retained
|
|
1153
|
+
* for scanning/equivocation surveys where a claimed seq is a lead to follow,
|
|
1154
|
+
* NOT for deriving the floor a verifier will refuse honest heads against.
|
|
1155
|
+
*/
|
|
1156
|
+
export function highestAnchoredSeq(calldatas, mandateDigestStr, accountant) {
|
|
1157
|
+
let best = null;
|
|
1158
|
+
for (const cd of calldatas) {
|
|
1159
|
+
const p = parseCommitmentAnchorCalldata(cd);
|
|
1160
|
+
if (!p || p.mandateDigest !== mandateDigestStr)
|
|
1161
|
+
continue;
|
|
1162
|
+
// Only the accountant's own anchors (with matching by=) set the floor; historic no-by= and foreign by= are ignored.
|
|
1163
|
+
if (accountant !== undefined && (p.by === undefined || p.by !== accountant))
|
|
1164
|
+
continue;
|
|
1165
|
+
if (best === null || p.seq > best)
|
|
1166
|
+
best = p.seq;
|
|
1167
|
+
}
|
|
1168
|
+
return best;
|
|
1169
|
+
}
|
|
1170
|
+
// ──────────────────── aggregate sibling delegation budget ────────────────────
|
|
1171
|
+
/**
|
|
1172
|
+
* The accountant-side check the delegation section's honest limit calls for:
|
|
1173
|
+
* given a parent grant and the set of children issued under it, every child
|
|
1174
|
+
* must be an authentic, correctly-narrowing child of THIS parent, digests
|
|
1175
|
+
* distinct, and the SUM of child caps must fit within the parent cap.
|
|
1176
|
+
*
|
|
1177
|
+
* HONESTY: this binds over the set it is GIVEN. Only the party that registers
|
|
1178
|
+
* delegations (the accountant, or the delegator's own ledger) knows the set is
|
|
1179
|
+
* complete — a verifier handed a partial set gets a partial answer. That is
|
|
1180
|
+
* the same completeness shape as spend, and the same parties close it.
|
|
1181
|
+
*/
|
|
1182
|
+
export function verifySiblingBudget(parentSm, children) {
|
|
1183
|
+
const reasons = [];
|
|
1184
|
+
const warnings = [];
|
|
1185
|
+
const shape = mandateShapeError(parentSm?.mandate);
|
|
1186
|
+
if (shape)
|
|
1187
|
+
return { ok: false, reasons: [`malformed parent mandate: ${shape}`], warnings };
|
|
1188
|
+
if (!verifyMandateSignature(parentSm))
|
|
1189
|
+
return { ok: false, reasons: ['parent mandate signature not authentic'], warnings };
|
|
1190
|
+
const parent = parentSm.mandate;
|
|
1191
|
+
const seen = new Set();
|
|
1192
|
+
let sum = 0n;
|
|
1193
|
+
for (let i = 0; i < children.length; i++) {
|
|
1194
|
+
const c = children[i];
|
|
1195
|
+
const cShape = mandateShapeError(c?.mandate);
|
|
1196
|
+
if (cShape) {
|
|
1197
|
+
reasons.push(`child ${i} malformed: ${cShape}`);
|
|
1198
|
+
break;
|
|
1199
|
+
}
|
|
1200
|
+
if (!verifyMandateSignature(c)) {
|
|
1201
|
+
reasons.push(`child ${i} signature not authentic`);
|
|
1202
|
+
break;
|
|
1203
|
+
}
|
|
1204
|
+
const narrow = narrowingError(c.mandate, parent);
|
|
1205
|
+
if (narrow) {
|
|
1206
|
+
reasons.push(`child ${i} is not a valid child of this parent: ${narrow}`);
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
const d = mandateDigest(c.mandate);
|
|
1210
|
+
if (seen.has(d)) {
|
|
1211
|
+
reasons.push(`child ${i} duplicates another child (same digest)`);
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
seen.add(d);
|
|
1215
|
+
sum += BigInt(c.mandate.cap);
|
|
1216
|
+
}
|
|
1217
|
+
if (reasons.length === 0 && sum > BigInt(parent.cap)) {
|
|
1218
|
+
reasons.push(`sibling caps sum to ${sum}, exceeding the parent cap ${parent.cap} — over-delegation`);
|
|
1219
|
+
}
|
|
1220
|
+
return { ok: reasons.length === 0, reasons, warnings };
|
|
1221
|
+
}
|
|
1222
|
+
/** Authorize one payment against the LEAF of a verified delegation chain. */
|
|
1223
|
+
export function checkDelegatedPayment(chain, payment, opts) {
|
|
1224
|
+
const chainVerdict = verifyDelegationChain(chain);
|
|
1225
|
+
if (!chainVerdict.ok)
|
|
1226
|
+
return chainVerdict;
|
|
1227
|
+
const leaf = chain[chain.length - 1];
|
|
1228
|
+
const v = checkPayment(leaf, payment, opts);
|
|
1229
|
+
return {
|
|
1230
|
+
...v,
|
|
1231
|
+
warnings: [...chainVerdict.warnings, ...v.warnings],
|
|
1232
|
+
...(chainVerdict.rootIssuer !== undefined ? { rootIssuer: chainVerdict.rootIssuer } : {}),
|
|
1233
|
+
};
|
|
1234
|
+
}
|