@bsvkey/x402-bsv-client 0.3.1 → 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.
- package/README.md +20 -6
- package/package.json +1 -1
- 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`),
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bsvkey/x402-bsv-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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",
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
10
|
-
//
|
|
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/
|
|
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', '
|
|
35
|
-
'
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
62
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 };
|