@bsvkey/x402-bsv-client 0.3.0 → 0.4.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 (3) hide show
  1. package/README.md +20 -6
  2. package/package.json +18 -18
  3. package/usage-receipt.js +81 -49
package/README.md CHANGED
@@ -88,12 +88,26 @@ const audit = await verifyReceiptChain(receipts, {
88
88
  `verifyReceiptChain` checks, across the whole chain, that: each signature
89
89
  recovers to the pinned broker key, every receipt is for your channel, the
90
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 }`.
91
+ (`cumSats`/`cumTokens`), spending never exceeds the funded amount, and each
92
+ **charge** recomputes from the published formula (you can be charged less, never
93
+ more). Single-receipt helpers: `verifyReceipt(receipt)` → `{ ok, signer }`,
94
+ `verifyCharge(receipt)`, and `verifyMeter(receipt, { system, prompt, completion })`.
93
95
 
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
96
+ ### Verify the meter (the token count itself)
97
+
98
+ The v2 receipt binds the exact bytes and is metered by a pinned, deterministic
99
+ tokenizer, so you recompute the token count from what you sent and received:
100
+
101
+ ```js
102
+ import { verifyMeter } from '@bsvkey/x402-bsv-client/usage-receipt';
103
+ const m = verifyMeter(receipt, { system, prompt, completion }); // { ok } / { ok:false, reason }
104
+ ```
105
+
106
+ **What this proves:** the broker signed these exact numbers (non-repudiable), the
107
+ token count is the published function of the exact bytes you exchanged, the charge
108
+ is the published function of those tokens, none were double-counted, and the
109
+ totals stay within what you funded. **What it does not prove:** that
110
+ `bsvkey-meter/1` equals a model provider's internal token count (it is BSVKey's
111
+ own published unit). Spec: https://inference.bsvkey.com/usage-receipts.md
98
112
 
99
113
  MIT.
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
- {
2
- "name": "@bsvkey/x402-bsv-client",
3
- "version": "0.3.0",
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
- "type": "module",
6
- "main": "index.js",
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" },
10
- "author": "Embryo Space Inc. (DBA BSVKey)",
11
- "homepage": "https://inference.bsvkey.com/v1/x402",
12
- "engines": { "node": ">=18" },
13
- "peerDependencies": { "@bsv/sdk": "^2" },
14
- "devDependencies": { "@bsv/sdk": "^2.4.2" },
15
- "keywords": ["x402", "bsv", "micropayments", "ai-agents", "402", "payments", "http-402", "usage-receipt", "verifiable"],
16
- "license": "MIT",
17
- "repository": { "type": "git", "url": "https://github.com/BSVKey/x402-bsv-client" }
18
- }
1
+ {
2
+ "name": "@bsvkey/x402-bsv-client",
3
+ "version": "0.4.0",
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
+ "type": "module",
6
+ "main": "index.js",
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" },
10
+ "author": "Embryo Space Inc. (DBA BSVKey)",
11
+ "homepage": "https://inference.bsvkey.com/v1/x402",
12
+ "engines": { "node": ">=18" },
13
+ "peerDependencies": { "@bsv/sdk": "^2" },
14
+ "devDependencies": { "@bsv/sdk": "^2.4.2" },
15
+ "keywords": ["x402", "bsv", "micropayments", "ai-agents", "402", "payments", "http-402", "usage-receipt", "verifiable"],
16
+ "license": "MIT",
17
+ "repository": { "type": "git", "url": "https://github.com/BSVKey/x402-bsv-client" }
18
+ }
package/usage-receipt.js CHANGED
@@ -1,22 +1,21 @@
1
- // Verify BSVKey inference usage receipts, client-side, offline.
1
+ // Verify BSVKey inference usage receipts (v2), client-side, offline.
2
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.
3
+ // The broker returns a signed `usageReceipt` on every settled prepaid-channel
4
+ // call. This verifies it with no round-trip and no trust in the broker's word:
5
+ // - verifyReceipt authorship + integrity (claimId + BSM signature recovery)
6
+ // - verifyCharge the sats are the published formula over the receipt's
7
+ // own token counts + pinned rates (overcharge is a dispute;
8
+ // undercharge, e.g. a channel cap, is allowed)
9
+ // - verifyMeter the token counts + byte digests recompute from the EXACT
10
+ // system/prompt/completion you hold, under the pinned
11
+ // tokenizer (bsvkey-meter/1). This is the 'last inch': the
12
+ // count is a deterministic function of the bytes exchanged.
13
+ // - verifyReceiptChain the whole channel: pinned broker key, channel binding,
14
+ // monotonic seq (no gap/replay), reconciling running totals
15
+ // within the funded amount, and each charge.
8
16
  //
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.
17
+ // Pin the broker key once from GET /v1/receipt-key. Peer dep: @bsv/sdk (v2).
18
+ // Spec: https://inference.bsvkey.com/usage-receipts.md
20
19
 
21
20
  import { createHash } from 'node:crypto';
22
21
 
@@ -27,15 +26,19 @@ async function sdk() {
27
26
  return _sdk;
28
27
  }
29
28
 
30
- export const RECEIPT_SCHEMA = 'bsvkey.usage-receipt/1';
29
+ export const RECEIPT_SCHEMA = 'bsvkey.usage-receipt/2';
30
+ export const METER_ID = 'bsvkey-meter/1';
31
31
 
32
32
  const CONTENT_FIELDS = [
33
- 'v', 'channelId', 'seq', 'model',
34
- 'inputTokens', 'outputTokens', 'sats',
35
- 'cumTokens', 'cumSats', 'fundedSats', 'timestamp',
33
+ 'v', 'channelId', 'seq', 'model', 'meter', 'pricebookId',
34
+ 'inputTokens', 'outputTokens', 'inputDigest', 'outputDigest',
35
+ 'rateInPer1k', 'rateOutPer1k', 'webSearchSats', 'discountPct', 'minChargeSats',
36
+ 'sats', 'cumTokens', 'cumSats', 'fundedSats', 'timestamp',
36
37
  ];
37
38
  const ALLOWED_KEYS = new Set([...CONTENT_FIELDS, 'claimId', 'signature', 'brokerPubKey']);
38
39
 
40
+ const sha256hex = (s) => createHash('sha256').update(String(s), 'utf8').digest('hex');
41
+
39
42
  function sortKeysDeep(value) {
40
43
  if (Array.isArray(value)) return value.map(sortKeysDeep);
41
44
  if (value !== null && typeof value === 'object') {
@@ -45,37 +48,46 @@ function sortKeysDeep(value) {
45
48
  }
46
49
  return value;
47
50
  }
48
- export function canonicalize(value) {
49
- return JSON.stringify(sortKeysDeep(value));
50
- }
51
+ export function canonicalize(value) { return JSON.stringify(sortKeysDeep(value)); }
51
52
  function pickContent(obj) {
52
53
  const c = {};
53
54
  for (const k of CONTENT_FIELDS) if (obj[k] !== undefined) c[k] = obj[k];
54
55
  return c;
55
56
  }
56
57
  export function computeClaimId(obj) {
57
- const hash = createHash('sha256').update(canonicalize(pickContent(obj)), 'utf8').digest('hex');
58
- return `0x${hash}`;
58
+ return `0x${createHash('sha256').update(canonicalize(pickContent(obj)), 'utf8').digest('hex')}`;
59
+ }
60
+
61
+ // --- the pinned meter (bsvkey-meter/1): byte-identical to the broker's -------
62
+ export function meterInputText(system, prompt) {
63
+ return (system ? String(system) + '\n\n' : '') + String(prompt || '');
64
+ }
65
+ export function estimateTokens(text) {
66
+ if (!text) return 0;
67
+ const byChars = Math.ceil(text.length / 4);
68
+ const byWords = Math.ceil(text.trim().split(/\s+/).filter(Boolean).length * 1.3);
69
+ return Math.max(byChars, byWords, 1);
70
+ }
71
+
72
+ // --- the frozen, integer-only charge formula (identical to the broker's) ------
73
+ export function computeChargeSats({ inputTokens, outputTokens, rateInPer1k, rateOutPer1k, webSearchSats = 0, discountPct = 0, minChargeSats }) {
74
+ const tokenSats = Math.ceil((inputTokens * rateInPer1k + outputTokens * rateOutPer1k) / 1000);
75
+ const gross = tokenSats + webSearchSats;
76
+ const afterDiscount = Math.ceil((gross * (100 - discountPct)) / 100);
77
+ return Math.max(minChargeSats, afterDiscount);
59
78
  }
60
79
 
61
- // Verify ONE receipt is internally consistent and recover its signer.
62
- // Returns { ok:true, signer } (signer = recovered pubkey hex) or { ok:false, reason }.
80
+ // Authorship + integrity: strict shape, claimId content-address, and recover the
81
+ // signer from the compact BSM signature. { ok:true, signer } / { ok:false, reason }.
63
82
  export async function verifyReceipt(receipt) {
64
83
  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
- }
84
+ for (const k of Object.keys(receipt)) if (!ALLOWED_KEYS.has(k)) return { ok: false, reason: `unknown_field:${k}` };
68
85
  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
- }
86
+ if (computeClaimId(receipt).toLowerCase() !== String(receipt.claimId).toLowerCase()) return { ok: false, reason: 'claimId_mismatch' };
73
87
  const { BSM, Utils, Signature, BigNumber } = await sdk();
74
88
  let raw;
75
89
  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
- }
90
+ if (!Array.isArray(raw) || raw.length !== 65 || raw[0] < 27 || raw[0] >= 35) return { ok: false, reason: 'bad_signature_encoding: not a 65-byte BSM compact signature' };
79
91
  const recoveryId = (raw[0] - 27) & 3;
80
92
  const msg = Utils.toArray(receipt.claimId, 'utf8');
81
93
  let recovered;
@@ -83,21 +95,39 @@ export async function verifyReceipt(receipt) {
83
95
  const sig = Signature.fromCompact(receipt.signature, 'base64');
84
96
  recovered = sig.RecoverPublicKey(recoveryId, new BigNumber(BSM.magicHash(msg)));
85
97
  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
- }
98
+ } catch (e) { return { ok: false, reason: `signature_recovery_failed: ${e.message}` }; }
89
99
  const signer = recovered.toString();
90
- if (receipt.brokerPubKey !== undefined && receipt.brokerPubKey !== signer) {
91
- return { ok: false, reason: 'brokerPubKey_does_not_match_recovered' };
92
- }
100
+ if (receipt.brokerPubKey !== undefined && receipt.brokerPubKey !== signer) return { ok: false, reason: 'brokerPubKey_does_not_match_recovered' };
93
101
  return { ok: true, signer };
94
102
  }
95
103
 
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 }.
104
+ // The charge is the published formula over the receipt's own fields. You can
105
+ // never be charged MORE than that; being charged less is allowed (channel cap).
106
+ export function verifyCharge(receipt) {
107
+ const expected = computeChargeSats(receipt);
108
+ if (receipt.sats > expected) return { ok: false, reason: 'overcharge', expected, got: receipt.sats };
109
+ if (receipt.sats < receipt.minChargeSats) return { ok: false, reason: 'below_min_charge', expected: receipt.minChargeSats, got: receipt.sats };
110
+ return receipt.sats === expected ? { ok: true } : { ok: true, note: 'undercharged' };
111
+ }
112
+
113
+ // The meter: recompute token counts + byte digests from the EXACT bytes you hold.
114
+ export function verifyMeter(receipt, { system, prompt, completion } = {}) {
115
+ if (receipt.meter !== METER_ID) return { ok: false, reason: `unknown_meter:${receipt.meter}` };
116
+ const input = meterInputText(system, prompt);
117
+ const output = String(completion || '');
118
+ if (sha256hex(input) !== receipt.inputDigest) return { ok: false, reason: 'inputDigest_mismatch' };
119
+ if (sha256hex(output) !== receipt.outputDigest) return { ok: false, reason: 'outputDigest_mismatch' };
120
+ if (estimateTokens(input) !== receipt.inputTokens) return { ok: false, reason: 'inputTokens_mismatch' };
121
+ if (estimateTokens(output) !== receipt.outputTokens) return { ok: false, reason: 'outputTokens_mismatch' };
122
+ return { ok: true };
123
+ }
124
+
125
+ // Verify a whole channel's receipt chain offline.
126
+ // opts.expectedSigner : broker key pinned from GET /v1/receipt-key
127
+ // opts.channelId : your channel
128
+ // opts.fundedSats : the channel's on-chain funded amount
129
+ // Also recomputes each charge. Returns { ok, count, cumSats, cumTokens } or
130
+ // { ok:false, reason, seq }.
101
131
  export async function verifyReceiptChain(receipts, opts = {}) {
102
132
  const list = [...receipts].sort((a, b) => (a.seq || 0) - (b.seq || 0));
103
133
  let prevSeq = 0, prevCumSats = 0, prevCumTokens = 0;
@@ -105,6 +135,8 @@ export async function verifyReceiptChain(receipts, opts = {}) {
105
135
  const v = await verifyReceipt(r);
106
136
  if (!v.ok) return { ok: false, reason: v.reason, seq: r.seq };
107
137
  if (opts.expectedSigner && v.signer !== opts.expectedSigner) return { ok: false, reason: 'signer_not_pinned_broker_key', seq: r.seq };
138
+ const c = verifyCharge(r);
139
+ if (!c.ok) return { ok: false, reason: c.reason, seq: r.seq };
108
140
  if (opts.channelId && r.channelId !== opts.channelId) return { ok: false, reason: 'wrong_channel', seq: r.seq };
109
141
  if (r.seq !== prevSeq + 1) return { ok: false, reason: prevSeq && r.seq === prevSeq ? 'replayed_seq' : 'seq_gap', seq: r.seq };
110
142
  if (r.cumSats !== prevCumSats + r.sats) return { ok: false, reason: 'cumSats_does_not_reconcile', seq: r.seq };