@bsvkey/x402-bsv-client 0.2.0 → 0.3.1

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 CHANGED
@@ -64,4 +64,36 @@ const xPayment = await buildX402Payment(body402, wif);
64
64
  server's address directly.
65
65
  - **Networks:** `bsv` (mainnet) and `bsv-testnet`. Testnet lets you dry-run for free.
66
66
 
67
+ ## Verify usage receipts
68
+
69
+ If you use the BSVKey inference broker over a **prepaid channel**, every settled
70
+ call returns a signed `usageReceipt`. This package verifies them offline, so you
71
+ can audit the broker's meter without trusting its word and without a round-trip.
72
+
73
+ ```js
74
+ import { verifyReceiptChain } from '@bsvkey/x402-bsv-client/usage-receipt';
75
+
76
+ // Pin the broker key once (GET https://inference.bsvkey.com/v1/receipt-key).
77
+ const { receiptPubKey } = await (await fetch('https://inference.bsvkey.com/v1/receipt-key')).json();
78
+
79
+ // `receipts` = the usageReceipt from each call on your channel.
80
+ const audit = await verifyReceiptChain(receipts, {
81
+ expectedSigner: receiptPubKey,
82
+ channelId: myChannelId,
83
+ fundedSats: myChannelFundedSats,
84
+ });
85
+ // { ok: true, count, cumSats, cumTokens } — or { ok: false, reason, seq }
86
+ ```
87
+
88
+ `verifyReceiptChain` checks, across the whole chain, that: each signature
89
+ recovers to the pinned broker key, every receipt is for your channel, the
90
+ sequence has no gap or replay, the running totals reconcile
91
+ (`cumSats`/`cumTokens`), and spending never exceeds the funded amount. There is
92
+ also a single-receipt `verifyReceipt(receipt)` → `{ ok, signer }`.
93
+
94
+ **What this proves:** the broker signed these exact numbers (non-repudiable),
95
+ none were double-counted, and the totals add up and stay within what you funded.
96
+ **What it does not prove:** that the token counts equal the model's true usage.
97
+ That is still the broker's meter. Spec: https://inference.bsvkey.com/usage-receipts.md
98
+
67
99
  MIT.
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "@bsvkey/x402-bsv-client",
3
- "version": "0.2.0",
4
- "description": "Pay an x402 HTTP 402 in BSV — the missing bsv-p2pkh payment builder for x402 agents.",
3
+ "version": "0.3.1",
4
+ "description": "Pay an x402 HTTP 402 in BSV — the missing bsv-p2pkh payment builder for x402 agents. Also verifies BSVKey inference usage receipts.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
- "exports": { ".": "./index.js" },
8
- "files": ["index.js", "example.js", "README.md", "LICENSE"],
7
+ "exports": { ".": "./index.js", "./usage-receipt": "./usage-receipt.js" },
8
+ "files": ["index.js", "usage-receipt.js", "example.js", "README.md", "LICENSE"],
9
+ "scripts": { "test": "node --test" },
9
10
  "author": "Embryo Space Inc. (DBA BSVKey)",
10
11
  "homepage": "https://inference.bsvkey.com/v1/x402",
11
12
  "engines": { "node": ">=18" },
12
13
  "peerDependencies": { "@bsv/sdk": "^2" },
13
- "keywords": ["x402", "bsv", "micropayments", "ai-agents", "402", "payments", "http-402"],
14
+ "devDependencies": { "@bsv/sdk": "^2.4.2" },
15
+ "keywords": ["x402", "bsv", "micropayments", "ai-agents", "402", "payments", "http-402", "usage-receipt", "verifiable"],
14
16
  "license": "MIT",
15
17
  "repository": { "type": "git", "url": "https://github.com/BSVKey/x402-bsv-client" }
16
18
  }
@@ -0,0 +1,116 @@
1
+ // Verify BSVKey inference usage receipts, client-side, offline.
2
+ //
3
+ // The BSVKey inference broker returns a signed `usageReceipt` on every settled
4
+ // prepaid-channel call. This module lets an agent audit the broker's meter
5
+ // WITHOUT trusting its word and WITHOUT a round-trip: it recovers the signer
6
+ // from the signature, and checks channel binding, a monotonic sequence (no
7
+ // replay/gap), and running totals bounded by the funded amount.
8
+ //
9
+ // Pin the broker's key once from GET /v1/receipt-key, then pass it as
10
+ // `expectedSigner` to verifyReceiptChain. Spec:
11
+ // https://inference.bsvkey.com/usage-receipts.md
12
+ //
13
+ // Peer dependency: @bsv/sdk (v2), the same one this package already needs to
14
+ // build payments. `verifyReceipt` / `verifyReceiptChain` are async.
15
+ //
16
+ // What it proves: the broker signed these exact numbers (non-repudiable), none
17
+ // were double-counted, and the totals reconcile and stay within what you funded.
18
+ // What it does NOT prove: that the token counts equal the model's true usage.
19
+ // That last inch is still the broker's meter.
20
+
21
+ import { createHash } from 'node:crypto';
22
+
23
+ let _sdk = null;
24
+ async function sdk() {
25
+ if (_sdk) return _sdk;
26
+ _sdk = await import('@bsv/sdk');
27
+ return _sdk;
28
+ }
29
+
30
+ export const RECEIPT_SCHEMA = 'bsvkey.usage-receipt/1';
31
+
32
+ const CONTENT_FIELDS = [
33
+ 'v', 'channelId', 'seq', 'model',
34
+ 'inputTokens', 'outputTokens', 'sats',
35
+ 'cumTokens', 'cumSats', 'fundedSats', 'timestamp',
36
+ ];
37
+ const ALLOWED_KEYS = new Set([...CONTENT_FIELDS, 'claimId', 'signature', 'brokerPubKey']);
38
+
39
+ function sortKeysDeep(value) {
40
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
41
+ if (value !== null && typeof value === 'object') {
42
+ const out = {};
43
+ for (const k of Object.keys(value).sort()) out[k] = sortKeysDeep(value[k]);
44
+ return out;
45
+ }
46
+ return value;
47
+ }
48
+ export function canonicalize(value) {
49
+ return JSON.stringify(sortKeysDeep(value));
50
+ }
51
+ function pickContent(obj) {
52
+ const c = {};
53
+ for (const k of CONTENT_FIELDS) if (obj[k] !== undefined) c[k] = obj[k];
54
+ return c;
55
+ }
56
+ export function computeClaimId(obj) {
57
+ const hash = createHash('sha256').update(canonicalize(pickContent(obj)), 'utf8').digest('hex');
58
+ return `0x${hash}`;
59
+ }
60
+
61
+ // Verify ONE receipt is internally consistent and recover its signer.
62
+ // Returns { ok:true, signer } (signer = recovered pubkey hex) or { ok:false, reason }.
63
+ export async function verifyReceipt(receipt) {
64
+ if (receipt === null || typeof receipt !== 'object') return { ok: false, reason: 'not_an_object' };
65
+ for (const k of Object.keys(receipt)) {
66
+ if (!ALLOWED_KEYS.has(k)) return { ok: false, reason: `unknown_field:${k}` };
67
+ }
68
+ if (receipt.v !== RECEIPT_SCHEMA) return { ok: false, reason: `bad_schema:${receipt.v}` };
69
+ const expected = computeClaimId(receipt);
70
+ if (expected.toLowerCase() !== String(receipt.claimId).toLowerCase()) {
71
+ return { ok: false, reason: 'claimId_mismatch' };
72
+ }
73
+ const { BSM, Utils, Signature, BigNumber } = await sdk();
74
+ let raw;
75
+ try { raw = Utils.toArray(receipt.signature, 'base64'); } catch (e) { return { ok: false, reason: `bad_signature_encoding: ${e.message}` }; }
76
+ if (!Array.isArray(raw) || raw.length !== 65 || raw[0] < 27 || raw[0] >= 35) {
77
+ return { ok: false, reason: 'bad_signature_encoding: not a 65-byte BSM compact signature' };
78
+ }
79
+ const recoveryId = (raw[0] - 27) & 3;
80
+ const msg = Utils.toArray(receipt.claimId, 'utf8');
81
+ let recovered;
82
+ try {
83
+ const sig = Signature.fromCompact(receipt.signature, 'base64');
84
+ recovered = sig.RecoverPublicKey(recoveryId, new BigNumber(BSM.magicHash(msg)));
85
+ if (!BSM.verify(msg, sig, recovered)) return { ok: false, reason: 'signature_invalid' };
86
+ } catch (e) {
87
+ return { ok: false, reason: `signature_recovery_failed: ${e.message}` };
88
+ }
89
+ const signer = recovered.toString();
90
+ if (receipt.brokerPubKey !== undefined && receipt.brokerPubKey !== signer) {
91
+ return { ok: false, reason: 'brokerPubKey_does_not_match_recovered' };
92
+ }
93
+ return { ok: true, signer };
94
+ }
95
+
96
+ // Verify a whole channel's receipt chain offline (the agent audit).
97
+ // opts.expectedSigner : the broker key pinned from GET /v1/receipt-key
98
+ // opts.channelId : your channel (all receipts must match)
99
+ // opts.fundedSats : the channel's on-chain funded amount (conservation cap)
100
+ // Returns { ok, count, cumSats, cumTokens } or { ok:false, reason, seq }.
101
+ export async function verifyReceiptChain(receipts, opts = {}) {
102
+ const list = [...receipts].sort((a, b) => (a.seq || 0) - (b.seq || 0));
103
+ let prevSeq = 0, prevCumSats = 0, prevCumTokens = 0;
104
+ for (const r of list) {
105
+ const v = await verifyReceipt(r);
106
+ if (!v.ok) return { ok: false, reason: v.reason, seq: r.seq };
107
+ if (opts.expectedSigner && v.signer !== opts.expectedSigner) return { ok: false, reason: 'signer_not_pinned_broker_key', seq: r.seq };
108
+ if (opts.channelId && r.channelId !== opts.channelId) return { ok: false, reason: 'wrong_channel', seq: r.seq };
109
+ if (r.seq !== prevSeq + 1) return { ok: false, reason: prevSeq && r.seq === prevSeq ? 'replayed_seq' : 'seq_gap', seq: r.seq };
110
+ if (r.cumSats !== prevCumSats + r.sats) return { ok: false, reason: 'cumSats_does_not_reconcile', seq: r.seq };
111
+ if (r.cumTokens !== prevCumTokens + r.inputTokens + r.outputTokens) return { ok: false, reason: 'cumTokens_does_not_reconcile', seq: r.seq };
112
+ if (opts.fundedSats !== undefined && r.cumSats > opts.fundedSats) return { ok: false, reason: 'cumSats_exceeds_funded', seq: r.seq };
113
+ prevSeq = r.seq; prevCumSats = r.cumSats; prevCumTokens = r.cumTokens;
114
+ }
115
+ return { ok: true, count: list.length, cumSats: prevCumSats, cumTokens: prevCumTokens };
116
+ }