@xfuel/verify 0.1.1 → 0.3.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.
Files changed (54) hide show
  1. package/README.md +62 -17
  2. package/dist/anchor-witness.d.ts +138 -0
  3. package/dist/anchor-witness.d.ts.map +1 -0
  4. package/dist/anchor-witness.js +365 -0
  5. package/dist/anchor-witness.js.map +1 -0
  6. package/dist/base-payer.d.ts +8 -1
  7. package/dist/base-payer.d.ts.map +1 -1
  8. package/dist/base-payer.js +64 -20
  9. package/dist/base-payer.js.map +1 -1
  10. package/dist/canonical-preimage.d.ts +9 -0
  11. package/dist/canonical-preimage.d.ts.map +1 -0
  12. package/dist/canonical-preimage.js +25 -0
  13. package/dist/canonical-preimage.js.map +1 -0
  14. package/dist/cli.d.ts +1 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +424 -25
  17. package/dist/cli.js.map +1 -1
  18. package/dist/head-binding.d.ts +24 -0
  19. package/dist/head-binding.d.ts.map +1 -0
  20. package/dist/head-binding.js +61 -0
  21. package/dist/head-binding.js.map +1 -0
  22. package/dist/index.d.ts +263 -48
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +919 -140
  25. package/dist/index.js.map +1 -1
  26. package/dist/issuer-history.d.ts +91 -0
  27. package/dist/issuer-history.d.ts.map +1 -0
  28. package/dist/issuer-history.js +292 -0
  29. package/dist/issuer-history.js.map +1 -0
  30. package/dist/jcs.d.ts +8 -0
  31. package/dist/jcs.d.ts.map +1 -0
  32. package/dist/jcs.js +64 -0
  33. package/dist/jcs.js.map +1 -0
  34. package/dist/jws.d.ts +23 -0
  35. package/dist/jws.d.ts.map +1 -1
  36. package/dist/jws.js +71 -1
  37. package/dist/jws.js.map +1 -1
  38. package/dist/payer.d.ts +16 -5
  39. package/dist/payer.d.ts.map +1 -1
  40. package/dist/payer.js +38 -26
  41. package/dist/payer.js.map +1 -1
  42. package/dist/preimage.d.ts +49 -0
  43. package/dist/preimage.d.ts.map +1 -0
  44. package/dist/preimage.js +203 -0
  45. package/dist/preimage.js.map +1 -0
  46. package/dist/receipt-lane.d.ts +234 -0
  47. package/dist/receipt-lane.d.ts.map +1 -0
  48. package/dist/receipt-lane.js +443 -0
  49. package/dist/receipt-lane.js.map +1 -0
  50. package/dist/refusal.d.ts +90 -0
  51. package/dist/refusal.d.ts.map +1 -0
  52. package/dist/refusal.js +226 -0
  53. package/dist/refusal.js.map +1 -0
  54. package/package.json +9 -5
package/dist/index.js CHANGED
@@ -12,16 +12,24 @@
12
12
  * See docs/specs/RECEIPT_V2_SEMANTICS.md for the verification algorithm.
13
13
  */
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.verifyIssuerJws = exports.resolvePinnedIssuerJwk = exports.canonicalSignedPayload = exports.BASE_SEPOLIA_RPC_URL = exports.ZK_VERIFIER_ADDRESS = exports.decodeJwsPayload = exports.receiptPayerClaimsFromEnvelope = exports.verifyPayerBinding = exports.SOLANA_USDC_MINT_DEVNET = exports.SOLANA_USDC_MINT_MAINNET = exports.SOLANA_RPC_URL = exports.fetchSolanaTransaction = exports.inferUsdcOutflowFromBalances = exports.extractUsdcTransfersFromTx = exports.isSolanaBase58Pubkey = exports.parseSolanaPaymentRef = exports.verifySolanaPayer = exports.BASE_RPC_URL = exports.ERC20_TRANSFER_TOPIC = exports.USDC_ADDRESSES = exports.fetchBaseTransactionReceipt = exports.sumUsdcTransfersFromPayer = exports.isEvmAddress = exports.parseBasePaymentRef = exports.verifyBasePayer = exports.computeInferenceBinding = exports.computePaymentCommitment = void 0;
15
+ exports.REFUSAL_SCHEMA = exports.verifyRefusal = exports.isRefusalDocument = exports.HEAD_BINDING_PAYLOAD_VERSION = exports.outerHeadDisagrees = exports.signedHeadBinding = exports.headBindingVerdict = exports.RECEIPT_LANE_BOUNDARY = exports.RECEIPT_LANE_ORDERING = exports.RECEIPT_LANE_RULE = exports.RECEIPT_LANE_SCHEMA = exports.anchorChangedSinceBinding = exports.deriveSettled = exports.deriveSettledBy = exports.receiptLaneDecision = exports.receiptLaneFromVerification = exports.buildReceiptLane = exports.jwksHostAllowed = exports.isPinnedTrustedJwk = exports.jwkThumbprint = exports.KEY_UNTRUSTED = exports.DEFAULT_TRUSTED_JWKS_HOSTS = exports.DEFAULT_TRUSTED_ISSUER_KIDS = exports.verifyIssuerJws = exports.resolvePinnedIssuerJwk = exports.canonicalSignedPayload = exports.BASE_SEPOLIA_RPC_URL = exports.ZK_VERIFIER_ADDRESS = exports.decodeJwsPayload = exports.receiptPayerClaimsFromEnvelope = exports.verifyPayerBinding = exports.SOLANA_USDC_MINT_DEVNET = exports.SOLANA_USDC_MINT_MAINNET = exports.SOLANA_RPC_URL = exports.fetchSolanaTransaction = exports.inferUsdcOutflowFromBalances = exports.extractUsdcTransfersFromTx = exports.isSolanaBase58Pubkey = exports.parseSolanaPaymentRef = exports.verifySolanaPayer = exports.BASE_RPC_URL = exports.ERC20_TRANSFER_TOPIC = exports.USDC_ADDRESSES = exports.fetchBaseTransactionReceipt = exports.sumUsdcTransfersFromPayer = exports.isEvmAddress = exports.parseBasePaymentRef = exports.verifyBasePayer = exports.computeInferenceBinding = exports.computePaymentCommitment = void 0;
16
+ exports.issuerHistoryDocumentHash = exports.readIssuerHistoryPin = exports.CANONICAL_PAYLOAD_VERSION = exports.verifyCanonicalPreimageBytes = exports.BASE_MAINNET_CHAIN_ID = exports.SOLANA_GENESIS = exports.ANCHOR_DOES_NOT_PROVE = exports.ANCHOR_PROVES = exports.leafHash = exports.parseAnchorMemo = exports.extractMemos = exports.verifyMerkleInclusion = exports.verifyAnchoredRoot = exports.REFUSAL_DOES_NOT_PROVE = exports.REFUSAL_PROVES = exports.REFUSAL_PAYLOAD_VERSION = void 0;
17
+ exports.canonicalPayloadVersion = canonicalPayloadVersion;
16
18
  exports.canonicalIssuerPayload = canonicalIssuerPayload;
19
+ exports.sumUsdcTransfersToPayee = sumUsdcTransfersToPayee;
20
+ exports.reconcileSettledTransfer = reconcileSettledTransfer;
17
21
  exports.verifyIssuerSignature = verifyIssuerSignature;
18
22
  exports.verifyIssuerSignatureWithJwks = verifyIssuerSignatureWithJwks;
19
23
  exports.verifyBinding = verifyBinding;
20
24
  exports.verifyNullifier = verifyNullifier;
21
25
  exports.hashCanonicalPayload = hashCanonicalPayload;
26
+ exports.claimIdVerdict = claimIdVerdict;
27
+ exports.diffOuterClaims = diffOuterClaims;
28
+ exports.fetchIssuerJwks = fetchIssuerJwks;
22
29
  exports.receiptPayerClaims = receiptPayerClaims;
23
30
  exports.verifyReceipt = verifyReceipt;
24
31
  const ethers_1 = require("ethers");
32
+ const anchor_witness_js_1 = require("./anchor-witness.js");
25
33
  const node_crypto_1 = require("node:crypto");
26
34
  const binding_js_1 = require("./binding.js");
27
35
  Object.defineProperty(exports, "computePaymentCommitment", { enumerable: true, get: function () { return binding_js_1.computePaymentCommitment; } });
@@ -50,6 +58,12 @@ Object.defineProperty(exports, "verifyPayerBinding", { enumerable: true, get: fu
50
58
  Object.defineProperty(exports, "receiptPayerClaimsFromEnvelope", { enumerable: true, get: function () { return payer_js_1.receiptPayerClaimsFromEnvelope; } });
51
59
  Object.defineProperty(exports, "decodeJwsPayload", { enumerable: true, get: function () { return payer_js_1.decodeJwsPayload; } });
52
60
  const jws_js_1 = require("./jws.js");
61
+ const head_binding_js_1 = require("./head-binding.js");
62
+ const receipt_lane_js_1 = require("./receipt-lane.js");
63
+ const refusal_js_1 = require("./refusal.js");
64
+ const preimage_js_1 = require("./preimage.js");
65
+ const issuer_history_js_1 = require("./issuer-history.js");
66
+ const canonical_preimage_js_1 = require("./canonical-preimage.js");
53
67
  /** ZKVerifierSP1 contract on Base mainnet. */
54
68
  exports.ZK_VERIFIER_ADDRESS = '0x9373499645292715a2275A78eD65B14215C41c06';
55
69
  /** Base Sepolia testnet RPC. */
@@ -59,28 +73,28 @@ const ZK_VERIFIER_ABI = [
59
73
  'function usedNullifiers(bytes32) view returns (bool)',
60
74
  ];
61
75
  /**
62
- * Canonical, order-stable payload an issuer signature covers.
63
- * MUST match `canonicalSignedPayload` in services/gateway/src/receipt.js exactly.
64
- *
65
- * Signed fields (15 total):
66
- * 1. task_id
67
- * 2. payment.rail
68
- * 3. payment.ref
69
- * 4. payment.gross_amount
70
- * 5. payment.net_amount
71
- * 6. payment.fee_amount
72
- * 7. payment.protocol_fee_bps ?? payment.fee_bps
73
- * 8. payment.platform_fee
74
- * 9. payment.platform_fee_bps
75
- * 10. provider_cogs.actual
76
- * 11. route.model
77
- * 12. route.model_commitment.commitment
78
- * 13. route.provider
79
- * 14. output.hash
80
- * 15. binding.expected_commitment
76
+ * HMAC payload version.
77
+ * Versions <= 7 keep the historical net/fee field list.
78
+ * Version 8 signs settled_amount plus the internal accounting breakdown.
79
+ * Lockstep with `canonicalPayloadVersion` in services/gateway/src/receipt.js
80
+ * for the v8 list (the v7 list here is the historical 15-field public formula).
81
81
  */
82
- function canonicalIssuerPayload(receipt) {
83
- return JSON.stringify([
82
+ function canonicalPayloadVersion(receipt) {
83
+ const stamped = receipt.hmac_attestation?.payload_version
84
+ ?? receipt.signature?.payload_version;
85
+ if (stamped != null)
86
+ return Number(stamped);
87
+ const issuerV = receipt.issuer_signature?.payload_version;
88
+ if (issuerV != null)
89
+ return Number(issuerV);
90
+ const payment = receipt.payment;
91
+ if (payment?.accounting || (payment && Object.prototype.hasOwnProperty.call(payment, 'settled_amount'))) {
92
+ return 8;
93
+ }
94
+ return 7;
95
+ }
96
+ function canonicalFieldsV7(receipt) {
97
+ return [
84
98
  receipt.task_id ?? null,
85
99
  receipt.payment?.rail ?? null,
86
100
  receipt.payment?.ref ?? null,
@@ -96,7 +110,204 @@ function canonicalIssuerPayload(receipt) {
96
110
  receipt.route?.provider ?? null,
97
111
  receipt.output?.hash ?? null,
98
112
  receipt.binding?.expected_commitment ?? null,
99
- ]);
113
+ ];
114
+ }
115
+ function canonicalFieldsV8(receipt) {
116
+ const breakdown = receipt.payment?.accounting?.internal_breakdown;
117
+ const caller = receipt.caller_binding;
118
+ return [
119
+ receipt.task_id ?? null,
120
+ receipt.payment?.rail ?? null,
121
+ receipt.payment?.ref ?? null,
122
+ receipt.payment?.gross_amount ?? null,
123
+ receipt.payment?.settled_amount ?? null,
124
+ breakdown?.route_margin_bps ?? null,
125
+ breakdown?.route_margin_amount ?? null,
126
+ breakdown?.receipt_floor_amount ?? null,
127
+ breakdown?.provider_cogs_amount ?? null,
128
+ breakdown?.tier2_proof_amount ?? null,
129
+ receipt.provider_cogs?.actual ?? null,
130
+ receipt.route?.model ?? null,
131
+ receipt.route?.model_commitment?.commitment ?? null,
132
+ receipt.route?.provider ?? null,
133
+ receipt.output?.hash ?? null,
134
+ receipt.binding?.expected_commitment ?? null,
135
+ caller?.payer_wallet ?? null,
136
+ caller?.agent_pubkey ?? null,
137
+ caller?.api_key_hash ?? null,
138
+ ];
139
+ }
140
+ /**
141
+ * Canonical, order-stable payload an HMAC issuer signature covers.
142
+ * v8 matches `canonicalSignedPayload` in services/gateway/src/receipt.js.
143
+ * v7 and earlier stay on the historical 15-field list so old signatures verify.
144
+ */
145
+ function canonicalIssuerPayload(receipt) {
146
+ const fields = canonicalPayloadVersion(receipt) >= 8
147
+ ? canonicalFieldsV8(receipt)
148
+ : canonicalFieldsV7(receipt);
149
+ return JSON.stringify(fields);
150
+ }
151
+ function topicAddress(topic) {
152
+ const hex = topic.toLowerCase().replace(/^0x/, '');
153
+ return `0x${hex.slice(-40)}`;
154
+ }
155
+ /**
156
+ * Sum USDC Transfer values to `payee` in a transaction's logs.
157
+ * Non-Transfer logs are ignored. When `usdcAddress` is set, other contracts are ignored.
158
+ */
159
+ function sumUsdcTransfersToPayee(logs, payee, usdcAddress) {
160
+ if (!payee || !(0, base_payer_js_1.isEvmAddress)(payee))
161
+ return null;
162
+ const payeeLower = payee.toLowerCase();
163
+ const usdcLower = usdcAddress?.toLowerCase();
164
+ let total = 0n;
165
+ let found = false;
166
+ for (const log of logs || []) {
167
+ if (usdcLower && log.address?.toLowerCase() !== usdcLower)
168
+ continue;
169
+ const topics = log.topics || [];
170
+ if (topics[0]?.toLowerCase() !== base_payer_js_1.ERC20_TRANSFER_TOPIC)
171
+ continue;
172
+ if (topics.length < 3 || !topics[2])
173
+ continue;
174
+ if (topicAddress(topics[2]) !== payeeLower)
175
+ continue;
176
+ found = true;
177
+ total += BigInt(log.data || '0x0');
178
+ }
179
+ return found ? total : null;
180
+ }
181
+ /**
182
+ * Payment facts for on-chain reconciliation.
183
+ * A JWS is used only after `verifyIssuerSignatureWithJwks` accepts the key.
184
+ * An untrusted or invalid JWS is not decoded into the comparison, and the
185
+ * unsigned outer `payment` copy is not a fallback for those claims.
186
+ * Receipts with no JWS still use the outer payment (legacy envelopes).
187
+ */
188
+ function trustedPaymentForReconcile(receipt, options) {
189
+ const outerVersion = Number(receipt.issuer_signature?.payload_version ?? canonicalPayloadVersion(receipt));
190
+ const jws = receipt.issuer_signature?.jws;
191
+ if (!jws) {
192
+ return { ok: true, version: outerVersion, payment: receipt.payment || {} };
193
+ }
194
+ const verification = verifyIssuerSignatureWithJwks(receipt, options.jwks, {
195
+ trustedKids: options.trustedKids,
196
+ });
197
+ if (!verification.valid || verification.key_trusted === false) {
198
+ return {
199
+ ok: false,
200
+ version: outerVersion,
201
+ reason: verification.reason || jws_js_1.KEY_UNTRUSTED,
202
+ };
203
+ }
204
+ const claims = verification.payload;
205
+ const claimPayment = (claims?.payment && typeof claims.payment === 'object')
206
+ ? claims.payment
207
+ : null;
208
+ if (!claimPayment) {
209
+ return { ok: false, version: outerVersion, reason: 'no_signed_amount' };
210
+ }
211
+ const version = typeof claims?.payload_version === 'number'
212
+ ? claims.payload_version
213
+ : outerVersion;
214
+ return { ok: true, version: Number(version), payment: claimPayment };
215
+ }
216
+ /**
217
+ * Optional on-chain reconciliation. Given a tx receipt's logs, the signed
218
+ * amount the payee was supposed to receive must equal the USDC Transfer to payee.
219
+ *
220
+ * The amount and payee come from JWS claims only after a key-trust check
221
+ * (JWKS by kid, or an embedded key whose thumbprint is a pinned kid). An
222
+ * untrusted signature does not contribute those facts.
223
+ *
224
+ * v8 compares `settled_amount` (falling back to `gross_amount`): that is the
225
+ * on-chain transfer. v7 and earlier compare `net_amount`, which those receipts
226
+ * defined as "amount after fees". A single direct transfer of gross will not
227
+ * match a net that subtracts a fee that never moved on chain.
228
+ */
229
+ function reconcileSettledTransfer(receipt, logs, options = {}) {
230
+ const trusted = trustedPaymentForReconcile(receipt, options);
231
+ if (!trusted.ok) {
232
+ return {
233
+ checked: false,
234
+ matches: false,
235
+ payload_version: trusted.version,
236
+ signed_field: null,
237
+ signed_amount: null,
238
+ transfer_amount: null,
239
+ payee: null,
240
+ reason: trusted.reason,
241
+ };
242
+ }
243
+ const { version, payment } = trusted;
244
+ const payee = payment.payee ?? null;
245
+ let signed_field = null;
246
+ let signed_amount = null;
247
+ if (version >= 8) {
248
+ if (payment.settled_amount != null && payment.settled_amount !== '') {
249
+ signed_field = 'settled_amount';
250
+ signed_amount = String(payment.settled_amount);
251
+ }
252
+ else if (payment.gross_amount != null && payment.gross_amount !== '') {
253
+ signed_field = 'gross_amount';
254
+ signed_amount = String(payment.gross_amount);
255
+ }
256
+ }
257
+ else {
258
+ signed_field = 'net_amount';
259
+ signed_amount = payment.net_amount != null ? String(payment.net_amount) : null;
260
+ }
261
+ if (!payee || !(0, base_payer_js_1.isEvmAddress)(payee)) {
262
+ return {
263
+ checked: false,
264
+ matches: false,
265
+ payload_version: version,
266
+ signed_field,
267
+ signed_amount,
268
+ transfer_amount: null,
269
+ payee,
270
+ reason: 'no_payee',
271
+ };
272
+ }
273
+ if (signed_amount == null) {
274
+ return {
275
+ checked: false,
276
+ matches: false,
277
+ payload_version: version,
278
+ signed_field,
279
+ signed_amount,
280
+ transfer_amount: null,
281
+ payee,
282
+ reason: 'no_signed_amount',
283
+ };
284
+ }
285
+ const transfer = sumUsdcTransfersToPayee(logs, payee, options.usdcAddress);
286
+ if (transfer == null) {
287
+ return {
288
+ checked: true,
289
+ matches: false,
290
+ payload_version: version,
291
+ signed_field,
292
+ signed_amount,
293
+ transfer_amount: null,
294
+ payee,
295
+ reason: 'no_transfer_to_payee',
296
+ };
297
+ }
298
+ const matches = BigInt(signed_amount) === transfer;
299
+ return {
300
+ checked: true,
301
+ matches,
302
+ payload_version: version,
303
+ signed_field,
304
+ signed_amount,
305
+ transfer_amount: transfer.toString(),
306
+ payee,
307
+ reason: matches
308
+ ? undefined
309
+ : `amount_mismatch: signed ${signed_field} ${signed_amount} !== transfer ${transfer.toString()}`,
310
+ };
100
311
  }
101
312
  /**
102
313
  * Verify a receipt's ES256 issuer signature against a JWK (public key).
@@ -146,59 +357,122 @@ function verifyIssuerSignature(receipt, jwk) {
146
357
  * @param receipt - Receipt JSON with issuer_signature
147
358
  * @param jwks - JWKS with keys array
148
359
  */
149
- function verifyIssuerSignatureWithJwks(receipt, jwks) {
360
+ function jwksCandidates(jwks, kid) {
361
+ const keys = jwks?.keys || [];
362
+ const es256 = keys.filter((k) => (0, jws_js_1.isEs256PublicJwk)(k) && (k.alg == null || k.alg === 'ES256'));
363
+ if (!kid)
364
+ return es256;
365
+ return es256.filter((k) => k.kid === kid);
366
+ }
367
+ /**
368
+ * Verify an issuer signature against trusted keys only.
369
+ *
370
+ * Trust, in order:
371
+ * 1. A JWKS entry matched by `kid` (supplied file or fetched issuer JWKS).
372
+ * 2. An embedded `issuer_jwk` whose RFC 7638 thumbprint equals a pinned trusted kid.
373
+ *
374
+ * An embedded key that merely verifies the bytes is `key untrusted`.
375
+ */
376
+ function verifyIssuerSignatureWithJwks(receipt, jwks, options = {}) {
150
377
  const sig = receipt.issuer_signature;
151
378
  if (!sig) {
152
- return { checked: false, valid: false, reason: 'no_issuer_signature' };
153
- }
154
- const pinned = (0, jws_js_1.resolvePinnedIssuerJwk)(receipt);
155
- if (pinned && sig.jws) {
156
- const jwsResult = (0, jws_js_1.verifyIssuerJws)(sig.jws, pinned);
157
- return {
158
- checked: true,
159
- valid: jwsResult.valid,
160
- kid: pinned.kid || sig.kid,
161
- reason: jwsResult.reason,
162
- };
379
+ return { checked: false, valid: false, key_trusted: false, reason: 'no_issuer_signature' };
163
380
  }
381
+ const trustedKids = options.trustedKids ?? jws_js_1.DEFAULT_TRUSTED_ISSUER_KIDS;
382
+ const header = sig.jws ? (0, jws_js_1.readJwsHeader)(sig.jws) : null;
383
+ const kid = header?.kid || sig.kid;
384
+ const embedded = (0, jws_js_1.resolvePinnedIssuerJwk)(receipt);
385
+ const pinned = !!(embedded && (0, jws_js_1.isPinnedTrustedJwk)(embedded, trustedKids));
164
386
  if (sig.jws) {
165
- if (!jwks || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
166
- return { checked: false, valid: false, reason: 'empty_jwks' };
167
- }
168
- const candidates = sig.kid
169
- ? jwks.keys.filter(k => k.kid === sig.kid && k.alg === 'ES256')
170
- : jwks.keys.filter(k => k.alg === 'ES256');
171
- if (candidates.length === 0) {
172
- return { checked: false, valid: false, reason: 'no_matching_key' };
173
- }
174
- for (const jwk of candidates) {
387
+ const fromJwks = jwksCandidates(jwks, kid);
388
+ for (const jwk of fromJwks) {
175
389
  const jwsResult = (0, jws_js_1.verifyIssuerJws)(sig.jws, jwk);
176
390
  if (jwsResult.valid) {
177
- return { checked: true, valid: true, kid: jwk.kid || sig.kid };
391
+ return {
392
+ checked: true,
393
+ valid: true,
394
+ key_trusted: true,
395
+ trust: 'jwks',
396
+ kid: jwk.kid || kid,
397
+ payload: jwsResult.payload,
398
+ };
399
+ }
400
+ }
401
+ if (pinned && embedded) {
402
+ const pinResult = (0, jws_js_1.verifyIssuerJws)(sig.jws, embedded);
403
+ if (pinResult.valid) {
404
+ return {
405
+ checked: true,
406
+ valid: true,
407
+ key_trusted: true,
408
+ trust: 'pinned_kid',
409
+ kid: embedded.kid || (0, jws_js_1.jwkThumbprint)(embedded),
410
+ payload: pinResult.payload,
411
+ };
178
412
  }
413
+ return {
414
+ checked: true,
415
+ valid: false,
416
+ key_trusted: true,
417
+ kid: kid || embedded.kid,
418
+ reason: pinResult.reason || 'signature_invalid',
419
+ };
179
420
  }
180
- return { checked: true, valid: false, reason: 'signature_invalid' };
421
+ if (embedded && (0, jws_js_1.verifyIssuerJws)(sig.jws, embedded).valid) {
422
+ return {
423
+ checked: true,
424
+ valid: false,
425
+ key_trusted: false,
426
+ kid: embedded.kid || kid,
427
+ reason: jws_js_1.KEY_UNTRUSTED,
428
+ };
429
+ }
430
+ if (fromJwks.length === 0 && !pinned) {
431
+ return {
432
+ checked: true,
433
+ valid: false,
434
+ key_trusted: false,
435
+ kid,
436
+ reason: jws_js_1.KEY_UNTRUSTED,
437
+ };
438
+ }
439
+ return {
440
+ checked: true,
441
+ valid: false,
442
+ key_trusted: fromJwks.length > 0 || pinned,
443
+ kid,
444
+ reason: 'signature_invalid',
445
+ };
181
446
  }
182
447
  if (!sig.value) {
183
- return { checked: false, valid: false, reason: 'no_issuer_signature' };
448
+ return { checked: false, valid: false, key_trusted: false, reason: 'no_issuer_signature' };
184
449
  }
185
- if (!jwks || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
186
- return { checked: false, valid: false, reason: 'empty_jwks' };
450
+ if (!jwks || !Array.isArray(jwks.keys)) {
451
+ return {
452
+ checked: false,
453
+ valid: false,
454
+ key_trusted: false,
455
+ kid: sig.kid,
456
+ reason: 'JWKS not provided — pass jwks option to verify issuer signature',
457
+ };
458
+ }
459
+ if (jwks.keys.length === 0) {
460
+ return { checked: false, valid: false, key_trusted: false, kid: sig.kid, reason: 'empty_jwks' };
187
461
  }
188
462
  // Find matching key by kid, or try all ES256 keys if no kid on signature
189
463
  const candidates = sig.kid
190
464
  ? jwks.keys.filter(k => k.kid === sig.kid && k.alg === 'ES256')
191
465
  : jwks.keys.filter(k => k.alg === 'ES256');
192
466
  if (candidates.length === 0) {
193
- return { checked: false, valid: false, reason: 'no_matching_key' };
467
+ return { checked: false, valid: false, key_trusted: false, kid: sig.kid, reason: 'no_matching_key' };
194
468
  }
195
469
  for (const jwk of candidates) {
196
470
  const result = verifyIssuerSignature(receipt, jwk);
197
471
  if (result.valid) {
198
- return result;
472
+ return { ...result, key_trusted: true, trust: 'jwks', kid: jwk.kid || sig.kid };
199
473
  }
200
474
  }
201
- return { checked: true, valid: false, reason: 'signature_invalid' };
475
+ return { checked: true, valid: false, key_trusted: true, kid: sig.kid, reason: 'signature_invalid' };
202
476
  }
203
477
  /**
204
478
  * Verify a receipt's payment binding locally (no network required).
@@ -206,8 +480,18 @@ function verifyIssuerSignatureWithJwks(receipt, jwks) {
206
480
  * This recomputes the commitment from the receipt's fields and compares
207
481
  * it to the stored `binding.expected_commitment`.
208
482
  */
483
+ function noCommitmentReason(rail) {
484
+ if (rail === 'unmetered' || rail === 'tfuel') {
485
+ return 'No binding present on receipt (unmetered or TFUEL rail)';
486
+ }
487
+ if (rail === 'usdc' || rail === 'reported') {
488
+ return 'No payment-binding commitment (expected_commitment is null)';
489
+ }
490
+ return 'No binding present on receipt (may be unmetered or TFUEL rail)';
491
+ }
209
492
  function verifyBinding(receipt) {
210
493
  const binding = receipt.binding;
494
+ const railHint = binding?.rail || receipt.payment?.rail || null;
211
495
  if (!binding) {
212
496
  return {
213
497
  verified: false,
@@ -215,7 +499,17 @@ function verifyBinding(receipt) {
215
499
  recomputed: null,
216
500
  matches: false,
217
501
  covers: [],
218
- reason: 'No binding present on receipt (may be unmetered or TFUEL rail)',
502
+ reason: noCommitmentReason(railHint),
503
+ };
504
+ }
505
+ if (binding.expected_commitment == null || binding.expected_commitment === '') {
506
+ return {
507
+ verified: false,
508
+ expected: null,
509
+ recomputed: null,
510
+ matches: false,
511
+ covers: binding.covers || [],
512
+ reason: noCommitmentReason(railHint),
219
513
  };
220
514
  }
221
515
  const paymentRef = receipt.payment?.ref ?? null;
@@ -329,88 +623,424 @@ function hashCanonicalPayload(receipt) {
329
623
  return (0, ethers_1.keccak256)((0, ethers_1.toUtf8Bytes)((0, exports.canonicalSignedPayload)(receipt)));
330
624
  }
331
625
  /**
332
- * Full receipt verification — binding + issuer signature + optional nullifier check.
626
+ * claim_id-era receipts include the key even when the value is null.
627
+ * Older payloads omit the key and stay valid.
628
+ */
629
+ function claimIdVerdict(claims) {
630
+ if (!claims || !Object.prototype.hasOwnProperty.call(claims, 'claim_id')) {
631
+ return 'not_present_legacy';
632
+ }
633
+ const payment = asRecord(claims.payment);
634
+ const ref = payment?.ref;
635
+ const hasRef = ref != null && String(ref) !== '';
636
+ const id = claims.claim_id;
637
+ const missing = id == null || id === '';
638
+ if (hasRef && missing)
639
+ return 'refused';
640
+ return 'ok';
641
+ }
642
+ function claimString(value) {
643
+ if (value == null || value === '')
644
+ return null;
645
+ return String(value);
646
+ }
647
+ function asRecord(value) {
648
+ if (!value || typeof value !== 'object' || Array.isArray(value))
649
+ return null;
650
+ return value;
651
+ }
652
+ function normFact(value, address = false) {
653
+ const text = claimString(value);
654
+ if (text == null)
655
+ return null;
656
+ return address ? text.toLowerCase() : text;
657
+ }
658
+ /**
659
+ * Fields a verifier may quote. Compared only when both the unsigned outer
660
+ * copy and the JWS payload carry a value.
661
+ */
662
+ const CLAIM_COMPARE = [
663
+ { field: 'task_id', outer: (r) => r.task_id, signed: (c) => c.task_id },
664
+ { field: 'payment.rail', outer: (r) => r.payment?.rail, signed: (c) => asRecord(c.payment)?.rail },
665
+ { field: 'payment.ref', outer: (r) => r.payment?.ref, signed: (c) => asRecord(c.payment)?.ref },
666
+ { field: 'payment.gross_amount', outer: (r) => r.payment?.gross_amount, signed: (c) => asRecord(c.payment)?.gross_amount },
667
+ { field: 'payment.settled_amount', outer: (r) => r.payment?.settled_amount, signed: (c) => asRecord(c.payment)?.settled_amount },
668
+ { field: 'payment.net_amount', outer: (r) => r.payment?.net_amount, signed: (c) => asRecord(c.payment)?.net_amount },
669
+ { field: 'payment.asset', outer: (r) => r.payment?.asset, signed: (c) => asRecord(c.payment)?.asset, address: true },
670
+ { field: 'payment.payee', outer: (r) => r.payment?.payee, signed: (c) => asRecord(c.payment)?.payee, address: true },
671
+ { field: 'caller_binding.payer_wallet', outer: (r) => r.caller_binding?.payer_wallet, signed: (c) => asRecord(c.caller_binding)?.payer_wallet, address: true },
672
+ { field: 'caller_binding.agent_pubkey', outer: (r) => r.caller_binding?.agent_pubkey, signed: (c) => asRecord(c.caller_binding)?.agent_pubkey },
673
+ { field: 'caller_binding.api_key_hash', outer: (r) => r.caller_binding?.api_key_hash, signed: (c) => asRecord(c.caller_binding)?.api_key_hash },
674
+ { field: 'claim_id', outer: (r) => r.claim_id, signed: (c) => c.claim_id },
675
+ { field: 'route.model', outer: (r) => r.route?.model, signed: (c) => asRecord(c.route)?.model },
676
+ { field: 'route.provider', outer: (r) => r.route?.provider, signed: (c) => asRecord(c.route)?.provider },
677
+ { field: 'output.hash', outer: (r) => r.output?.hash, signed: (c) => asRecord(c.output)?.hash },
678
+ { field: 'binding.expected_commitment', outer: (r) => r.binding?.expected_commitment, signed: (c) => asRecord(c.binding)?.expected_commitment },
679
+ { field: 'provider_cogs.actual', outer: (r) => r.provider_cogs?.actual, signed: (c) => asRecord(c.provider_cogs)?.actual },
680
+ ];
681
+ /** Flag unsigned outer copies that disagree with the JWS payload. */
682
+ function diffOuterClaims(receipt, claims) {
683
+ if (!claims)
684
+ return [];
685
+ const mismatches = [];
686
+ for (const spec of CLAIM_COMPARE) {
687
+ const outer = normFact(spec.outer(receipt), spec.address);
688
+ const signed = normFact(spec.signed(claims), spec.address);
689
+ if (outer == null || signed == null || outer === signed)
690
+ continue;
691
+ mismatches.push({
692
+ field: spec.field,
693
+ outer: claimString(spec.outer(receipt)),
694
+ signed: claimString(spec.signed(claims)),
695
+ });
696
+ }
697
+ // v9 head pair. Compared when the outer key is present, including a null
698
+ // signed hash against a filled-in outer copy. v8 claims skip this.
699
+ if ((0, head_binding_js_1.headBindingVerdict)(claims) === 'ok') {
700
+ const disagree = (0, head_binding_js_1.outerHeadDisagrees)(receipt, claims);
701
+ if (disagree) {
702
+ const outerValue = disagree === 'tree_head_hash' ? receipt.tree_head_hash : receipt.tolerance;
703
+ const signedValue = disagree === 'tree_head_hash' ? claims.tree_head_hash : claims.tolerance;
704
+ mismatches.push({
705
+ field: disagree,
706
+ outer: outerValue == null ? null : JSON.stringify(outerValue),
707
+ signed: signedValue == null ? null : JSON.stringify(signedValue),
708
+ });
709
+ }
710
+ }
711
+ return mismatches;
712
+ }
713
+ function receiptViewFromClaims(receipt, claims) {
714
+ const payment = asRecord(claims.payment);
715
+ const binding = asRecord(claims.binding);
716
+ const route = asRecord(claims.route);
717
+ const output = asRecord(claims.output);
718
+ const caller = asRecord(claims.caller_binding);
719
+ const modelCommitment = claimString(route?.model_commitment);
720
+ return {
721
+ ...receipt,
722
+ task_id: claimString(claims.task_id) || receipt.task_id,
723
+ payment: {
724
+ rail: claimString(payment?.rail) ?? undefined,
725
+ ref: claimString(payment?.ref),
726
+ gross_amount: claimString(payment?.gross_amount),
727
+ settled_amount: claimString(payment?.settled_amount),
728
+ net_amount: claimString(payment?.net_amount),
729
+ asset: claimString(payment?.asset),
730
+ payee: claimString(payment?.payee),
731
+ fee_amount: claimString(payment?.fee_amount),
732
+ protocol_fee_bps: typeof payment?.protocol_fee_bps === 'number' ? payment.protocol_fee_bps : null,
733
+ platform_fee: claimString(payment?.platform_fee),
734
+ platform_fee_bps: typeof payment?.platform_fee_bps === 'number' ? payment.platform_fee_bps : null,
735
+ },
736
+ route: {
737
+ model: claimString(route?.model) ?? undefined,
738
+ provider: claimString(route?.provider) ?? undefined,
739
+ model_commitment: modelCommitment ? { commitment: modelCommitment } : null,
740
+ },
741
+ output: output?.hash ? { hash: claimString(output.hash) ?? undefined } : null,
742
+ caller_binding: caller
743
+ ? {
744
+ payer_wallet: claimString(caller.payer_wallet),
745
+ agent_pubkey: claimString(caller.agent_pubkey),
746
+ api_key_hash: claimString(caller.api_key_hash),
747
+ }
748
+ : null,
749
+ provider_cogs: asRecord(claims.provider_cogs)?.actual != null
750
+ ? { actual: claimString(asRecord(claims.provider_cogs)?.actual) ?? undefined }
751
+ : receipt.provider_cogs,
752
+ binding: {
753
+ expected_commitment: binding && 'expected_commitment' in binding
754
+ ? (claimString(binding.expected_commitment) ?? undefined)
755
+ : undefined,
756
+ amount: claimString(binding?.amount)
757
+ || claimString(payment?.net_amount)
758
+ || claimString(payment?.gross_amount)
759
+ || '0',
760
+ rail: claimString(binding?.rail) || claimString(payment?.rail) || undefined,
761
+ covers: Array.isArray(binding?.covers) ? binding.covers : ['payment', 'settlement'],
762
+ model_commitment: claimString(binding?.model_commitment) || modelCommitment,
763
+ output_hash: claimString(binding?.output_hash) || claimString(output?.hash),
764
+ },
765
+ };
766
+ }
767
+ function payerClaimsFromPayload(payload) {
768
+ const payment = asRecord(payload.payment);
769
+ const caller = asRecord(payload.caller_binding);
770
+ return {
771
+ payment: {
772
+ ref: claimString(payment?.ref),
773
+ gross_amount: claimString(payment?.gross_amount),
774
+ payee: claimString(payment?.payee),
775
+ asset: claimString(payment?.asset),
776
+ rail: claimString(payment?.rail),
777
+ },
778
+ caller_binding: caller ? { payer_wallet: claimString(caller.payer_wallet) } : null,
779
+ };
780
+ }
781
+ function factsFromClaims(claims) {
782
+ if (!claims) {
783
+ return { hub: null, model: null, amount_usdc: null, tx: null, output_hash: null };
784
+ }
785
+ const payment = asRecord(claims.payment);
786
+ const route = asRecord(claims.route);
787
+ const output = asRecord(claims.output);
788
+ return {
789
+ hub: claimString(route?.provider),
790
+ model: claimString(route?.model),
791
+ amount_usdc: claimString(payment?.gross_amount),
792
+ tx: claimString(payment?.ref),
793
+ output_hash: claimString(output?.hash),
794
+ };
795
+ }
796
+ function issuerJwksUri(receipt) {
797
+ const fromVerification = receipt.verification?.jwks_uri;
798
+ if (typeof fromVerification === 'string' && fromVerification.startsWith('https://'))
799
+ return fromVerification;
800
+ const jws = receipt.issuer_signature?.jws;
801
+ const header = jws ? (0, jws_js_1.readJwsHeader)(jws) : null;
802
+ if (header?.jku && header.jku.startsWith('https://'))
803
+ return header.jku;
804
+ return null;
805
+ }
806
+ function httpsUrl(uri) {
807
+ try {
808
+ return new URL(uri).protocol === 'https:';
809
+ }
810
+ catch {
811
+ return false;
812
+ }
813
+ }
814
+ function mergeJwks(primary, extra) {
815
+ const keys = [...(primary?.keys || []), ...(extra?.keys || [])];
816
+ if (keys.length === 0)
817
+ return undefined;
818
+ const seen = new Set();
819
+ const deduped = [];
820
+ for (const key of keys) {
821
+ const id = `${key.kid || ''}:${key.x || ''}:${key.y || ''}`;
822
+ if (seen.has(id))
823
+ continue;
824
+ seen.add(id);
825
+ deduped.push(key);
826
+ }
827
+ return { keys: deduped };
828
+ }
829
+ /** Fetch a JWKS document. Caller decides whether the URL is a trust root. */
830
+ async function fetchIssuerJwks(uri, fetchImpl = globalThis.fetch) {
831
+ if (!httpsUrl(uri))
832
+ throw new Error('jwks_uri must be https');
833
+ const res = await fetchImpl(uri);
834
+ if (!res.ok)
835
+ throw new Error(`jwks_fetch_failed: ${res.status}`);
836
+ const body = await res.json();
837
+ if (!body || !Array.isArray(body.keys))
838
+ throw new Error('jwks_malformed');
839
+ return body;
840
+ }
841
+ /**
842
+ * Full receipt verification — trusted issuer signature, signed-claim facts,
843
+ * optional on-chain payer and nullifier checks.
333
844
  *
334
- * @param receipt The Chit402 receipt JSON
335
- * @param options.jwks JWKS for issuer signature verification (no network fetch)
336
- * @param options.checkNullifier Whether to verify nullifier on-chain (requires network)
337
- * @param options.rpcUrl RPC URL for on-chain checks
845
+ * Facts (amount, payer, payee, asset, tx, model) are taken only from a JWS
846
+ * that verified under a trusted key. The embedded `issuer_jwk` is not trusted
847
+ * unless its RFC 7638 thumbprint is a pinned kid, or the same key is in a JWKS.
338
848
  */
339
- /** Extract JWS claims for payer binding when present. */
849
+ /** Extract JWS claims for payer binding. Prefers the JWS payload over the outer copy. */
340
850
  function receiptPayerClaims(receipt) {
341
851
  return (0, payer_js_1.receiptPayerClaimsFromEnvelope)(receipt);
342
852
  }
853
+ function normalizeBoundRoot(root) {
854
+ if (root == null || root === '')
855
+ return null;
856
+ const hex = String(root).replace(/^0x/i, '').toLowerCase();
857
+ return /^[0-9a-f]{64}$/.test(hex) ? hex : null;
858
+ }
859
+ /**
860
+ * True when the supplied head is the signed prefix, or a later head whose
861
+ * inclusion proof contains this receipt's leaf.
862
+ */
863
+ function suppliedHeadCovers(receipt, signedRoot, head, inclusion) {
864
+ const signed = normalizeBoundRoot(signedRoot);
865
+ const supplied = normalizeBoundRoot(head?.root);
866
+ if (!signed || !supplied)
867
+ return false;
868
+ if (signed === supplied)
869
+ return true;
870
+ if (!inclusion || !Array.isArray(inclusion.proof) || inclusion.leaf_index == null)
871
+ return true;
872
+ if (receipt.task_id == null)
873
+ return false;
874
+ const row = inclusion.row_hash ?? receipt.book_chain?.row_hash ?? '';
875
+ const leaf = (0, anchor_witness_js_1.leafHash)(Buffer.from(`${receipt.task_id}|${row}`));
876
+ if (typeof inclusion.leaf === 'string' && /^[0-9a-fA-F]{64}$/.test(inclusion.leaf)
877
+ && inclusion.leaf.toLowerCase() !== leaf.toString('hex')) {
878
+ return false;
879
+ }
880
+ const size = inclusion.tree_size ?? head.tree_size;
881
+ if (size == null || !Number.isInteger(Number(size)))
882
+ return false;
883
+ return (0, anchor_witness_js_1.verifyMerkleInclusion)(leaf, Number(inclusion.leaf_index), Number(size), supplied, inclusion.proof);
884
+ }
343
885
  async function verifyReceipt(receipt, options = {}) {
344
886
  const errors = [];
345
- // Verify binding locally
346
- const binding = verifyBinding(receipt);
347
- if (!binding.matches && binding.expected) {
348
- errors.push('Payment binding mismatch');
349
- }
350
- // Verify issuer signature — pinned JWK in receipt, optional JWKS file, or legacy detached sig
351
- let issuer_signature;
352
- const pinned = (0, jws_js_1.resolvePinnedIssuerJwk)(receipt);
353
- if (pinned && receipt.issuer_signature?.jws) {
354
- const jwsResult = (0, jws_js_1.verifyIssuerJws)(receipt.issuer_signature.jws, pinned);
355
- issuer_signature = {
356
- checked: true,
357
- valid: jwsResult.valid,
358
- kid: pinned.kid || receipt.issuer_signature.kid,
359
- reason: jwsResult.reason,
360
- };
361
- if (!jwsResult.valid) {
362
- errors.push(`Issuer signature invalid: ${jwsResult.reason}`);
887
+ const trustedKids = options.trustedKids ?? jws_js_1.DEFAULT_TRUSTED_ISSUER_KIDS;
888
+ const trustedHosts = options.trustedJwksHosts ?? jws_js_1.DEFAULT_TRUSTED_JWKS_HOSTS;
889
+ let jwks = options.jwks;
890
+ if (options.jwksUri || options.fetchJwks) {
891
+ const uri = options.jwksUri || issuerJwksUri(receipt);
892
+ const allowed = !!uri && (options.jwksUri ? httpsUrl(uri) : (0, jws_js_1.jwksHostAllowed)(uri, trustedHosts));
893
+ if (uri && allowed) {
894
+ try {
895
+ const fetched = await fetchIssuerJwks(uri, options.fetchImpl);
896
+ jwks = mergeJwks(jwks, fetched);
897
+ }
898
+ catch (err) {
899
+ errors.push(`JWKS fetch failed: ${err instanceof Error ? err.message : String(err)}`);
900
+ }
901
+ }
902
+ else if (options.jwksUri) {
903
+ errors.push('JWKS URI rejected — only https URLs are fetched');
904
+ }
905
+ else if (options.fetchJwks && uri) {
906
+ errors.push(`JWKS host untrusted: ${uri}`);
363
907
  }
364
908
  }
365
- else if (options.jwks) {
366
- issuer_signature = verifyIssuerSignatureWithJwks(receipt, options.jwks);
909
+ let issuer_signature;
910
+ if (receipt.issuer_signature?.jws || receipt.issuer_signature?.value) {
911
+ issuer_signature = verifyIssuerSignatureWithJwks(receipt, jwks, { trustedKids });
367
912
  if (issuer_signature.checked && !issuer_signature.valid) {
368
- errors.push(`Issuer signature invalid: ${issuer_signature.reason}`);
913
+ errors.push(issuer_signature.reason === jws_js_1.KEY_UNTRUSTED
914
+ ? jws_js_1.KEY_UNTRUSTED
915
+ : `Issuer signature invalid: ${issuer_signature.reason}`);
369
916
  }
370
917
  }
371
918
  else if (receipt.issuer_signature) {
372
919
  issuer_signature = {
373
920
  checked: false,
374
921
  valid: false,
922
+ key_trusted: false,
375
923
  kid: receipt.issuer_signature.kid,
376
- reason: receipt.issuer_signature.jws
377
- ? 'No pinned issuer_jwk and JWKS not provided — pass jwks option or use a receipt with issuer_signature.issuer_jwk'
378
- : 'JWKS not provided — pass jwks option to verify issuer signature',
924
+ reason: 'JWKS not provided — pass jwks option to verify issuer signature',
379
925
  };
380
926
  }
381
927
  else {
382
928
  issuer_signature = {
383
929
  checked: false,
384
930
  valid: false,
931
+ key_trusted: false,
385
932
  reason: 'No issuer signature present on receipt',
386
933
  };
387
934
  }
388
- // Verify payer on-chain when requested (Base USDC or Solana USDC)
935
+ const decoded = receipt.issuer_signature?.jws
936
+ ? (0, payer_js_1.decodeJwsPayload)(receipt.issuer_signature.jws)
937
+ : null;
938
+ const claim_mismatches = diffOuterClaims(receipt, decoded);
939
+ for (const mismatch of claim_mismatches) {
940
+ errors.push(`outer/signed mismatch: ${mismatch.field} (outer ${mismatch.outer}, signed ${mismatch.signed})`);
941
+ }
942
+ const verifiedClaims = issuer_signature.valid ? issuer_signature.payload : undefined;
943
+ const claim_id = claimIdVerdict(issuer_signature.valid ? verifiedClaims : decoded);
944
+ if (issuer_signature.valid && claim_id === 'refused') {
945
+ errors.push('payment.ref is set and claim_id is null');
946
+ }
947
+ const headVerdict = (0, head_binding_js_1.headBindingVerdict)(issuer_signature.valid ? verifiedClaims : null);
948
+ const headMissing = headVerdict === 'missing';
949
+ if (headMissing) {
950
+ errors.push('payload v9 requires tree_head_hash and tolerance inside signed claims');
951
+ }
952
+ const signedBinding = headVerdict === 'ok' ? (0, head_binding_js_1.signedHeadBinding)(verifiedClaims) : null;
953
+ // A published head may differ from the signed prefix. That is not a failure.
954
+ // An inclusion proof, when one is supplied, must be for this receipt's leaf
955
+ // (`task_id|row_hash`), not an arbitrary hash already in the tree.
956
+ let headMismatch = false;
957
+ if (signedBinding?.tree_head_hash && options.head?.root && options.inclusion) {
958
+ if (!suppliedHeadCovers(receipt, signedBinding.tree_head_hash, options.head, options.inclusion)) {
959
+ headMismatch = true;
960
+ errors.push('tree_head_mismatch');
961
+ }
962
+ }
963
+ let binding;
964
+ if (receipt.issuer_signature?.jws) {
965
+ if (verifiedClaims) {
966
+ binding = verifyBinding(receiptViewFromClaims(receipt, verifiedClaims));
967
+ }
968
+ else {
969
+ binding = {
970
+ verified: false,
971
+ expected: null,
972
+ recomputed: null,
973
+ matches: false,
974
+ covers: [],
975
+ reason: issuer_signature.reason === jws_js_1.KEY_UNTRUSTED
976
+ ? 'Binding not checked — issuer key untrusted'
977
+ : 'Binding not checked — issuer signature not verified',
978
+ };
979
+ }
980
+ }
981
+ else {
982
+ binding = verifyBinding(receipt);
983
+ }
984
+ if (!binding.matches && binding.expected) {
985
+ errors.push('Payment binding mismatch');
986
+ }
987
+ const signedPayerClaims = verifiedClaims ? payerClaimsFromPayload(verifiedClaims) : null;
389
988
  let payer;
390
989
  if (options.checkPayer) {
391
- const payerResult = await (0, payer_js_1.verifyPayerBinding)(receiptPayerClaims(receipt), {
392
- rpcUrl: options.rpcUrl,
393
- solanaRpcUrl: options.solanaRpcUrl,
394
- fetchSolanaTransaction: options.fetchSolanaTransaction,
395
- fetchBaseReceipt: options.fetchBaseReceipt,
396
- });
990
+ if (!signedPayerClaims) {
991
+ payer = {
992
+ checked: false,
993
+ valid: false,
994
+ reason: 'Payer facts come from verified signed claims — issuer signature is not trusted',
995
+ };
996
+ }
997
+ else {
998
+ const payerResult = await (0, payer_js_1.verifyPayerBinding)(signedPayerClaims, {
999
+ rpcUrl: options.rpcUrl,
1000
+ solanaRpcUrl: options.solanaRpcUrl,
1001
+ fetchSolanaTransaction: options.fetchSolanaTransaction,
1002
+ fetchBaseReceipt: options.fetchBaseReceipt,
1003
+ });
1004
+ payer = {
1005
+ checked: payerResult.checked,
1006
+ valid: payerResult.valid,
1007
+ rail: payerResult.rail,
1008
+ reason: payerResult.reason,
1009
+ payer_wallet: payerResult.payerWallet ?? signedPayerClaims.caller_binding?.payer_wallet ?? null,
1010
+ payee: signedPayerClaims.payment?.payee ?? null,
1011
+ asset: signedPayerClaims.payment?.asset ?? null,
1012
+ amount: payerResult.expectedAmount ?? signedPayerClaims.payment?.gross_amount ?? null,
1013
+ };
1014
+ if (payerResult.checked && !payerResult.valid) {
1015
+ errors.push(`Payer binding mismatch: ${payerResult.reason}`);
1016
+ }
1017
+ }
1018
+ }
1019
+ else if (signedPayerClaims?.caller_binding?.payer_wallet && signedPayerClaims.payment?.ref) {
397
1020
  payer = {
398
- checked: payerResult.checked,
399
- valid: payerResult.valid,
400
- rail: payerResult.rail,
401
- reason: payerResult.reason,
1021
+ checked: false,
1022
+ valid: false,
1023
+ payer_wallet: signedPayerClaims.caller_binding.payer_wallet,
1024
+ payee: signedPayerClaims.payment.payee ?? null,
1025
+ asset: signedPayerClaims.payment.asset ?? null,
1026
+ amount: signedPayerClaims.payment.gross_amount ?? null,
1027
+ reason: 'On-chain payer check not requested — pass checkPayer: true',
402
1028
  };
403
- if (payerResult.checked && !payerResult.valid) {
404
- errors.push(`Payer binding mismatch: ${payerResult.reason}`);
405
- }
406
1029
  }
407
- else if (receipt.caller_binding?.payer_wallet && receipt.payment?.ref) {
1030
+ else if (!receipt.issuer_signature?.jws && receipt.caller_binding?.payer_wallet && receipt.payment?.ref) {
408
1031
  payer = {
409
1032
  checked: false,
410
1033
  valid: false,
411
1034
  reason: 'On-chain payer check not requested — pass checkPayer: true',
412
1035
  };
413
1036
  }
1037
+ else if (receipt.issuer_signature?.jws && !issuer_signature.valid) {
1038
+ payer = {
1039
+ checked: false,
1040
+ valid: false,
1041
+ reason: 'Payer facts come from verified signed claims — issuer signature is not trusted',
1042
+ };
1043
+ }
414
1044
  else {
415
1045
  payer = {
416
1046
  checked: false,
@@ -418,7 +1048,6 @@ async function verifyReceipt(receipt, options = {}) {
418
1048
  reason: 'No payer_wallet or payment.ref to verify',
419
1049
  };
420
1050
  }
421
- // Verify nullifier on-chain if requested
422
1051
  let nullifier;
423
1052
  if (options.checkNullifier && receipt.proof?.nullifier) {
424
1053
  nullifier = await verifyNullifier(receipt, {
@@ -439,64 +1068,166 @@ async function verifyReceipt(receipt, options = {}) {
439
1068
  : 'On-chain check not requested',
440
1069
  };
441
1070
  }
442
- // Extract frozen fields
443
- const hub = receipt.route?.provider ?? null;
444
- const model = receipt.route?.model ?? null;
445
- const amount_usdc = receipt.payment?.gross_amount ?? null;
446
- const tx = receipt.payment?.ref ?? null;
447
- const output_hash = receipt.output?.hash ?? null;
448
- // Determine overall status with strict signature semantics:
449
- // - If JWKS provided and signature invalid/kid mismatch/no matching key → 'failed'
450
- // - If receipt has issuer_signature but no JWKS provided → 'partial' (cannot verify)
451
- // - Unsigned receipts can be 'partial' or 'verified' based on binding
452
- let overall;
453
- // Check for signature verification failure (JWKS provided but signature invalid)
454
- const jwksProvided = !!options.jwks;
1071
+ const facts = factsFromClaims(verifiedClaims);
1072
+ const warnings = [];
1073
+ const preimages = await (0, preimage_js_1.verifyPublishedPreimages)(receipt, {
1074
+ requirePreimages: options.requirePreimages === true,
1075
+ fetchImpl: options.fetchImpl,
1076
+ trustedHosts,
1077
+ });
1078
+ if (!preimages.ok)
1079
+ errors.push(...preimages.errors);
1080
+ const signedPayloadHash = verifiedClaims && 'payload_hash' in verifiedClaims
1081
+ ? verifiedClaims.payload_hash
1082
+ : null;
1083
+ const storedPreimage = typeof receipt.issuer_signature?.canonical_preimage === 'string'
1084
+ ? receipt.issuer_signature.canonical_preimage
1085
+ : null;
1086
+ let canonicalPreimageFailed = false;
1087
+ if (storedPreimage != null) {
1088
+ const storedCheck = (0, canonical_preimage_js_1.verifyCanonicalPreimageBytes)(storedPreimage, signedPayloadHash);
1089
+ if (!storedCheck.ok) {
1090
+ canonicalPreimageFailed = true;
1091
+ if (storedCheck.reason)
1092
+ errors.push(`canonical preimage: ${storedCheck.reason}`);
1093
+ }
1094
+ }
1095
+ if (typeof options.canonicalPreimage === 'string') {
1096
+ const fileCheck = (0, canonical_preimage_js_1.verifyCanonicalPreimageBytes)(options.canonicalPreimage, signedPayloadHash);
1097
+ if (!fileCheck.ok) {
1098
+ canonicalPreimageFailed = true;
1099
+ if (fileCheck.reason)
1100
+ errors.push(`canonical preimage: ${fileCheck.reason}`);
1101
+ }
1102
+ }
1103
+ const issuedAt = (verifiedClaims && 'iat' in verifiedClaims ? verifiedClaims.iat : null)
1104
+ ?? decoded?.iat
1105
+ ?? receipt.created_at
1106
+ ?? null;
1107
+ const historyPin = (0, issuer_history_js_1.readIssuerHistoryPin)(verifiedClaims);
1108
+ const payloadVersion = Number(verifiedClaims?.payload_version);
1109
+ // Payload v10 signs the history pin. A missing pin fails even when the
1110
+ // caller did not pass a history file or ask for a fetch.
1111
+ const pinRequired = !options.skipIssuerHistory
1112
+ && Number.isFinite(payloadVersion)
1113
+ && payloadVersion >= canonical_preimage_js_1.CANONICAL_PAYLOAD_VERSION;
1114
+ const historyAsked = pinRequired || (!options.skipIssuerHistory && !!(options.issuerHistory
1115
+ || options.issuerHistoryBytes
1116
+ || options.fetchIssuerHistory
1117
+ || options.strictIssuerHistory
1118
+ || options.issuerHistoryUrl
1119
+ || historyPin));
1120
+ const issuer_history = historyAsked
1121
+ ? await (0, issuer_history_js_1.checkReceiptIssuerHistory)(receipt, {
1122
+ document: options.issuerHistory ?? null,
1123
+ documentBytes: options.issuerHistoryBytes ?? null,
1124
+ fetchHistory: options.fetchIssuerHistory === true || options.strictIssuerHistory === true,
1125
+ strict: options.strictIssuerHistory === true,
1126
+ historyUrl: options.issuerHistoryUrl ?? null,
1127
+ jwks,
1128
+ trustedKids,
1129
+ fetchImpl: options.fetchImpl,
1130
+ trustedHosts,
1131
+ issuedAt,
1132
+ kid: issuer_signature.kid ?? receipt.issuer_signature?.kid ?? null,
1133
+ pin: historyPin,
1134
+ requirePin: pinRequired,
1135
+ })
1136
+ : {
1137
+ checked: false,
1138
+ ok: true,
1139
+ unreachable: false,
1140
+ warning: null,
1141
+ reason: null,
1142
+ kid: issuer_signature.kid ?? receipt.issuer_signature?.kid ?? null,
1143
+ };
1144
+ if (issuer_history.warning)
1145
+ warnings.push(issuer_history.warning);
1146
+ if (issuer_history.checked && !issuer_history.ok && issuer_history.reason) {
1147
+ errors.push(`issuer history: ${issuer_history.reason}`);
1148
+ }
455
1149
  const hasIssuerSig = !!receipt.issuer_signature;
456
- const hasPinnedJwk = !!pinned && !!receipt.issuer_signature?.jws;
457
- const issuerSigInvalid = (jwksProvided || hasPinnedJwk) && hasIssuerSig && !issuer_signature.valid;
458
- // Binding failure
459
- const bindingFailed = binding.expected && !binding.matches;
460
- const payerFailed = options.checkPayer && payer.checked && !payer.valid;
461
- if (issuerSigInvalid || bindingFailed || payerFailed) {
462
- // JWKS provided but signature invalid, OR binding mismatch → failed
1150
+ const jwksWasSupplied = !!(options.jwks || options.jwksUri || (options.fetchJwks && jwks));
1151
+ const claimRefused = issuer_signature.valid && claim_id === 'refused';
1152
+ const signatureFailed = hasIssuerSig && !issuer_signature.valid && (!!issuer_signature.checked
1153
+ || jwksWasSupplied
1154
+ || issuer_signature.reason === jws_js_1.KEY_UNTRUSTED
1155
+ || issuer_signature.reason === 'no_matching_key'
1156
+ || issuer_signature.reason === 'empty_jwks');
1157
+ const mismatchFailed = claim_mismatches.length > 0;
1158
+ const bindingFailed = !!(binding.expected && !binding.matches);
1159
+ const payerFailed = !!(options.checkPayer && payer.checked && !payer.valid);
1160
+ const nullifierFailed = !!(options.checkNullifier
1161
+ && receipt.proof?.nullifier
1162
+ && nullifier.anchored === false);
1163
+ const signatureUnchecked = hasIssuerSig && !issuer_signature.checked;
1164
+ let overall;
1165
+ const preimageFailed = !preimages.ok;
1166
+ const historyFailed = issuer_history.checked && !issuer_history.ok;
1167
+ if (signatureFailed || mismatchFailed || bindingFailed || payerFailed || nullifierFailed || claimRefused || headMissing || headMismatch || preimageFailed || historyFailed || canonicalPreimageFailed) {
463
1168
  overall = 'failed';
464
1169
  }
465
- else if (hasIssuerSig && !jwksProvided && !hasPinnedJwk) {
466
- // Receipt has signature but no offline key material → partial
1170
+ else if (signatureUnchecked) {
467
1171
  overall = 'partial';
468
1172
  }
469
- else if (binding.matches
1173
+ else if (!binding.expected && !hasIssuerSig && !options.checkPayer) {
1174
+ overall = 'partial';
1175
+ }
1176
+ else if ((binding.matches || !binding.expected)
470
1177
  && (!options.checkPayer || payer.valid)
471
- && (!options.checkNullifier || nullifier.anchored)) {
472
- // Binding matches, signature OK (or no signature), nullifier OK → verified
1178
+ && (!options.checkNullifier || !receipt.proof?.nullifier || nullifier.anchored === true)
1179
+ && (issuer_signature.valid || !hasIssuerSig)) {
473
1180
  overall = 'verified';
474
1181
  }
475
- else if (binding.matches
476
- || issuer_signature.valid
477
- || (options.checkPayer && payer.valid)
478
- || (nullifier.verified && nullifier.anchored)) {
479
- // At least one check passed → partial
480
- overall = 'partial';
481
- }
482
- else if (!binding.expected && !receipt.proof?.nullifier && !hasIssuerSig) {
483
- // No binding/sig/nullifier to verify (unmetered/demo) → partial
1182
+ else {
484
1183
  overall = 'partial';
485
1184
  }
486
- else {
1185
+ // A refusal is a different document. Recognition uses the signed JWS
1186
+ // schema, not only the unsigned outer schema. A valid issuer signature
1187
+ // here must not be reported as a verified payment.
1188
+ const verifiedSchema = typeof verifiedClaims?.schema === 'string' ? verifiedClaims.schema : null;
1189
+ if (verifiedSchema === refusal_js_1.REFUSAL_SCHEMA || (0, refusal_js_1.isRefusalDocument)(receipt)) {
1190
+ errors.push('refusal document is not a payment receipt');
487
1191
  overall = 'failed';
488
1192
  }
1193
+ const signedPayment = asRecord(verifiedClaims?.payment);
1194
+ const signedSettlement = asRecord(verifiedClaims?.settlement);
1195
+ const receipt_lane = (0, receipt_lane_js_1.receiptLaneFromVerification)({
1196
+ receipt,
1197
+ claims: {
1198
+ payment: signedPayment ? {
1199
+ ref: typeof signedPayment.ref === 'string' ? signedPayment.ref : null,
1200
+ rail: typeof signedPayment.rail === 'string' ? signedPayment.rail : null,
1201
+ collected: typeof signedPayment.collected === 'boolean' ? signedPayment.collected : null,
1202
+ } : null,
1203
+ settlement: signedSettlement ? {
1204
+ kind: typeof signedSettlement.kind === 'string' ? signedSettlement.kind : null,
1205
+ } : null,
1206
+ },
1207
+ issuerValid: issuer_signature.valid === true,
1208
+ payer: { checked: payer.checked, valid: payer.valid },
1209
+ head: options.head ?? null,
1210
+ });
489
1211
  return {
490
1212
  receipt_id: receipt.task_id,
491
1213
  binding,
492
1214
  issuer_signature,
493
1215
  payer,
494
1216
  nullifier,
495
- output_hash,
496
- hub,
497
- model,
498
- amount_usdc,
499
- tx,
1217
+ output_hash: facts.output_hash,
1218
+ hub: facts.hub,
1219
+ model: facts.model,
1220
+ amount_usdc: facts.amount_usdc,
1221
+ tx: facts.tx,
1222
+ claim_mismatches,
1223
+ claim_id,
1224
+ head_binding: signedBinding
1225
+ ? { tree_head_hash: signedBinding.tree_head_hash, tolerance: signedBinding.tolerance }
1226
+ : null,
1227
+ receipt_lane,
1228
+ preimages,
1229
+ issuer_history,
1230
+ warnings,
500
1231
  overall,
501
1232
  errors,
502
1233
  };
@@ -504,6 +1235,51 @@ async function verifyReceipt(receipt, options = {}) {
504
1235
  var jws_js_2 = require("./jws.js");
505
1236
  Object.defineProperty(exports, "resolvePinnedIssuerJwk", { enumerable: true, get: function () { return jws_js_2.resolvePinnedIssuerJwk; } });
506
1237
  Object.defineProperty(exports, "verifyIssuerJws", { enumerable: true, get: function () { return jws_js_2.verifyIssuerJws; } });
1238
+ Object.defineProperty(exports, "DEFAULT_TRUSTED_ISSUER_KIDS", { enumerable: true, get: function () { return jws_js_2.DEFAULT_TRUSTED_ISSUER_KIDS; } });
1239
+ Object.defineProperty(exports, "DEFAULT_TRUSTED_JWKS_HOSTS", { enumerable: true, get: function () { return jws_js_2.DEFAULT_TRUSTED_JWKS_HOSTS; } });
1240
+ Object.defineProperty(exports, "KEY_UNTRUSTED", { enumerable: true, get: function () { return jws_js_2.KEY_UNTRUSTED; } });
1241
+ Object.defineProperty(exports, "jwkThumbprint", { enumerable: true, get: function () { return jws_js_2.jwkThumbprint; } });
1242
+ Object.defineProperty(exports, "isPinnedTrustedJwk", { enumerable: true, get: function () { return jws_js_2.isPinnedTrustedJwk; } });
1243
+ Object.defineProperty(exports, "jwksHostAllowed", { enumerable: true, get: function () { return jws_js_2.jwksHostAllowed; } });
1244
+ var receipt_lane_js_2 = require("./receipt-lane.js");
1245
+ Object.defineProperty(exports, "buildReceiptLane", { enumerable: true, get: function () { return receipt_lane_js_2.buildReceiptLane; } });
1246
+ Object.defineProperty(exports, "receiptLaneFromVerification", { enumerable: true, get: function () { return receipt_lane_js_2.receiptLaneFromVerification; } });
1247
+ Object.defineProperty(exports, "receiptLaneDecision", { enumerable: true, get: function () { return receipt_lane_js_2.receiptLaneDecision; } });
1248
+ Object.defineProperty(exports, "deriveSettledBy", { enumerable: true, get: function () { return receipt_lane_js_2.deriveSettledBy; } });
1249
+ Object.defineProperty(exports, "deriveSettled", { enumerable: true, get: function () { return receipt_lane_js_2.deriveSettled; } });
1250
+ Object.defineProperty(exports, "anchorChangedSinceBinding", { enumerable: true, get: function () { return receipt_lane_js_2.anchorChangedSinceBinding; } });
1251
+ Object.defineProperty(exports, "RECEIPT_LANE_SCHEMA", { enumerable: true, get: function () { return receipt_lane_js_2.RECEIPT_LANE_SCHEMA; } });
1252
+ Object.defineProperty(exports, "RECEIPT_LANE_RULE", { enumerable: true, get: function () { return receipt_lane_js_2.RECEIPT_LANE_RULE; } });
1253
+ Object.defineProperty(exports, "RECEIPT_LANE_ORDERING", { enumerable: true, get: function () { return receipt_lane_js_2.RECEIPT_LANE_ORDERING; } });
1254
+ Object.defineProperty(exports, "RECEIPT_LANE_BOUNDARY", { enumerable: true, get: function () { return receipt_lane_js_2.RECEIPT_LANE_BOUNDARY; } });
1255
+ var head_binding_js_2 = require("./head-binding.js");
1256
+ Object.defineProperty(exports, "headBindingVerdict", { enumerable: true, get: function () { return head_binding_js_2.headBindingVerdict; } });
1257
+ Object.defineProperty(exports, "signedHeadBinding", { enumerable: true, get: function () { return head_binding_js_2.signedHeadBinding; } });
1258
+ Object.defineProperty(exports, "outerHeadDisagrees", { enumerable: true, get: function () { return head_binding_js_2.outerHeadDisagrees; } });
1259
+ Object.defineProperty(exports, "HEAD_BINDING_PAYLOAD_VERSION", { enumerable: true, get: function () { return head_binding_js_2.HEAD_BINDING_PAYLOAD_VERSION; } });
1260
+ var refusal_js_2 = require("./refusal.js");
1261
+ Object.defineProperty(exports, "isRefusalDocument", { enumerable: true, get: function () { return refusal_js_2.isRefusalDocument; } });
1262
+ Object.defineProperty(exports, "verifyRefusal", { enumerable: true, get: function () { return refusal_js_2.verifyRefusal; } });
1263
+ Object.defineProperty(exports, "REFUSAL_SCHEMA", { enumerable: true, get: function () { return refusal_js_2.REFUSAL_SCHEMA; } });
1264
+ Object.defineProperty(exports, "REFUSAL_PAYLOAD_VERSION", { enumerable: true, get: function () { return refusal_js_2.REFUSAL_PAYLOAD_VERSION; } });
1265
+ Object.defineProperty(exports, "REFUSAL_PROVES", { enumerable: true, get: function () { return refusal_js_2.REFUSAL_PROVES; } });
1266
+ Object.defineProperty(exports, "REFUSAL_DOES_NOT_PROVE", { enumerable: true, get: function () { return refusal_js_2.REFUSAL_DOES_NOT_PROVE; } });
1267
+ var anchor_witness_js_2 = require("./anchor-witness.js");
1268
+ Object.defineProperty(exports, "verifyAnchoredRoot", { enumerable: true, get: function () { return anchor_witness_js_2.verifyAnchoredRoot; } });
1269
+ Object.defineProperty(exports, "verifyMerkleInclusion", { enumerable: true, get: function () { return anchor_witness_js_2.verifyMerkleInclusion; } });
1270
+ Object.defineProperty(exports, "extractMemos", { enumerable: true, get: function () { return anchor_witness_js_2.extractMemos; } });
1271
+ Object.defineProperty(exports, "parseAnchorMemo", { enumerable: true, get: function () { return anchor_witness_js_2.parseAnchorMemo; } });
1272
+ Object.defineProperty(exports, "leafHash", { enumerable: true, get: function () { return anchor_witness_js_2.leafHash; } });
1273
+ Object.defineProperty(exports, "ANCHOR_PROVES", { enumerable: true, get: function () { return anchor_witness_js_2.ANCHOR_PROVES; } });
1274
+ Object.defineProperty(exports, "ANCHOR_DOES_NOT_PROVE", { enumerable: true, get: function () { return anchor_witness_js_2.ANCHOR_DOES_NOT_PROVE; } });
1275
+ Object.defineProperty(exports, "SOLANA_GENESIS", { enumerable: true, get: function () { return anchor_witness_js_2.SOLANA_GENESIS; } });
1276
+ Object.defineProperty(exports, "BASE_MAINNET_CHAIN_ID", { enumerable: true, get: function () { return anchor_witness_js_2.BASE_MAINNET_CHAIN_ID; } });
1277
+ var canonical_preimage_js_2 = require("./canonical-preimage.js");
1278
+ Object.defineProperty(exports, "verifyCanonicalPreimageBytes", { enumerable: true, get: function () { return canonical_preimage_js_2.verifyCanonicalPreimageBytes; } });
1279
+ Object.defineProperty(exports, "CANONICAL_PAYLOAD_VERSION", { enumerable: true, get: function () { return canonical_preimage_js_2.CANONICAL_PAYLOAD_VERSION; } });
1280
+ var issuer_history_js_2 = require("./issuer-history.js");
1281
+ Object.defineProperty(exports, "readIssuerHistoryPin", { enumerable: true, get: function () { return issuer_history_js_2.readIssuerHistoryPin; } });
1282
+ Object.defineProperty(exports, "issuerHistoryDocumentHash", { enumerable: true, get: function () { return issuer_history_js_2.issuerHistoryDocumentHash; } });
507
1283
  exports.default = {
508
1284
  verifyBinding,
509
1285
  verifyIssuerSignature,
@@ -521,7 +1297,10 @@ exports.default = {
521
1297
  computePaymentCommitment: binding_js_1.computePaymentCommitment,
522
1298
  computeInferenceBinding: binding_js_1.computeInferenceBinding,
523
1299
  canonicalIssuerPayload,
1300
+ canonicalPayloadVersion,
524
1301
  canonicalSignedPayload: exports.canonicalSignedPayload,
1302
+ reconcileSettledTransfer,
1303
+ sumUsdcTransfersToPayee,
525
1304
  hashCanonicalPayload,
526
1305
  ZK_VERIFIER_ADDRESS: exports.ZK_VERIFIER_ADDRESS,
527
1306
  BASE_RPC_URL: base_payer_js_1.BASE_RPC_URL,