@bsvkey/x402-bsv-client 0.2.0 → 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.
- package/README.md +32 -0
- package/package.json +18 -16
- package/usage-receipt.js +116 -0
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
|
-
{
|
|
2
|
-
"name": "@bsvkey/x402-bsv-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pay an x402 HTTP 402 in BSV — the missing bsv-p2pkh payment builder for x402 agents.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "index.js",
|
|
7
|
-
"exports": { ".": "./index.js" },
|
|
8
|
-
"files": ["index.js", "example.js", "README.md", "LICENSE"],
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
|
|
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
|
+
}
|
package/usage-receipt.js
ADDED
|
@@ -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
|
+
}
|