openzoo 0.50.37 → 0.50.39
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/bin/claude-zoo.js +100 -0
- package/bin/openzoo.js +8 -0
- package/lib/cursorbackend.js +464 -99
- package/lib/grokbotAccount.js +134 -8
- package/lib/grokbotUploads.js +168 -0
- package/lib/grokbotweb-shim.js +766 -0
- package/lib/grokbotweb.js +933 -0
- package/lib/grokcli.js +47 -22
- package/lib/ozSpendChip.js +482 -0
- package/lib/pay.js +17 -24
- package/lib/proxy.js +33 -11
- package/lib/spendProof.js +307 -0
- package/lib/x402.js +3 -1
- package/package.json +1 -1
package/lib/pay.js
CHANGED
|
@@ -53,21 +53,16 @@ export class UnderfundedError extends Error {
|
|
|
53
53
|
* that gets funded mid-session recovers on its own.
|
|
54
54
|
*/
|
|
55
55
|
/**
|
|
56
|
-
* BALANCE CACHE —
|
|
56
|
+
* BALANCE CACHE — positive balances SWR for a few seconds; zeros are live.
|
|
57
57
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* Stale-while-revalidate: a cached value is returned IMMEDIATELY and a refresh is
|
|
65
|
-
* kicked off in the background when it is older than the TTL, so no request ever
|
|
66
|
-
* waits on the network for it. Only a cold cache (first call of the process)
|
|
67
|
-
* blocks. A payment decrements the cached figure locally so a burst of calls
|
|
68
|
-
* cannot overdraw between refreshes.
|
|
58
|
+
* A per-request probe used to add seconds to every call. A 60s SWR of $0 is
|
|
59
|
+
* worse: `openzoo balance` is live, so a TOKEN top-up shows in the CLI while
|
|
60
|
+
* the still-running proxy keeps throwing "underfunded" until the TTL dies.
|
|
61
|
+
* Restarting Grok Bot does not restart the proxy, so four app restarts do
|
|
62
|
+
* nothing. Re-read zeros (the just-funded case) and keep a short TTL on the
|
|
63
|
+
* rest. A payment still decrements the cached figure so a burst cannot overdraw.
|
|
69
64
|
*/
|
|
70
|
-
const BALANCE_TTL_MS = Number(process.env.OPENZOO_BALANCE_TTL_MS ||
|
|
65
|
+
const BALANCE_TTL_MS = Number(process.env.OPENZOO_BALANCE_TTL_MS || 8_000);
|
|
71
66
|
const balanceCache = new Map(); // key -> { raw, ui, at, refreshing }
|
|
72
67
|
|
|
73
68
|
const balKey = (owner, mint) => `${owner}|${mint}`;
|
|
@@ -75,14 +70,14 @@ const balKey = (owner, mint) => `${owner}|${mint}`;
|
|
|
75
70
|
async function cachedTokenBalance(connection, owner, mint, { force = false } = {}) {
|
|
76
71
|
const key = balKey(owner.toBase58 ? owner.toBase58() : String(owner), mint);
|
|
77
72
|
const hit = balanceCache.get(key);
|
|
78
|
-
const
|
|
79
|
-
|
|
73
|
+
const zero = !!(hit && hit.raw === 0n);
|
|
74
|
+
const fresh = hit && !force && !zero && (Date.now() - hit.at) < BALANCE_TTL_MS;
|
|
75
|
+
if (hit && !force && !zero) {
|
|
80
76
|
if (!fresh && !hit.refreshing) {
|
|
81
|
-
// SEMI-FREQUENT REFRESH: fire and forget, so the caller is never blocked.
|
|
82
77
|
hit.refreshing = true;
|
|
83
78
|
tokenBalance(connection, owner, mint)
|
|
84
79
|
.then((b) => balanceCache.set(key, { raw: b.raw, ui: b.ui, at: Date.now(), refreshing: false }))
|
|
85
|
-
.catch(() => { hit.refreshing = false; });
|
|
80
|
+
.catch(() => { hit.refreshing = false; });
|
|
86
81
|
}
|
|
87
82
|
return { raw: hit.raw, ui: hit.ui, cached: true, ageMs: Date.now() - hit.at };
|
|
88
83
|
}
|
|
@@ -101,7 +96,7 @@ function debitCachedBalance(owner, mint, amount) {
|
|
|
101
96
|
/** Test seam. */
|
|
102
97
|
export function resetBalanceCache() { balanceCache.clear(); }
|
|
103
98
|
|
|
104
|
-
const RAIL_MEMO_MS = Number(process.env.OPENZOO_RAIL_MEMO_MS ||
|
|
99
|
+
const RAIL_MEMO_MS = Number(process.env.OPENZOO_RAIL_MEMO_MS || 15_000);
|
|
105
100
|
const underfundedUntil = new Map(); // asset -> epoch ms after which to re-try it
|
|
106
101
|
let lastGoodAsset = null;
|
|
107
102
|
|
|
@@ -213,12 +208,7 @@ export class PayClient {
|
|
|
213
208
|
const rail = railOf(accept);
|
|
214
209
|
if (rail === 'solana') {
|
|
215
210
|
const need = BigInt(accept.maxAmountRequired);
|
|
216
|
-
|
|
217
|
-
// Never declare a wallet short on a STALE read — the expensive top-up path
|
|
218
|
-
// and the underfunded error both deserve a live number.
|
|
219
|
-
if (bal.raw < need && bal.cached) {
|
|
220
|
-
bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true });
|
|
221
|
-
}
|
|
211
|
+
const bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true });
|
|
222
212
|
// SHORT IS SHORT. The 402 quotes the raw native mint, so there is nothing
|
|
223
213
|
// to convert and no pool to walk: the wallet either holds the asset or it
|
|
224
214
|
// does not. This branch used to run resolvePool + poolState + a wrap
|
|
@@ -463,6 +453,9 @@ export class PayClient {
|
|
|
463
453
|
// operator spent an evening searching Solscan for it as a tx hash —
|
|
464
454
|
// an id a machine can log must be one a human can look up.
|
|
465
455
|
tx: settle?.transaction || settle?.txHash || settle?.signature || null,
|
|
456
|
+
// SVM memo actually placed on the transfer (seller extra.memo, else the
|
|
457
|
+
// 16-byte hex uniqueness nonce). Never the ownerSignature.
|
|
458
|
+
memo: payment?.memo || accept?.extra?.memo || null,
|
|
466
459
|
rail: railOf(accept),
|
|
467
460
|
billedUsd: accept.extra?.billedUsd,
|
|
468
461
|
directUsd: accept.extra?.directUsd,
|
package/lib/proxy.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Readable } from 'node:stream';
|
|
|
7
7
|
import {
|
|
8
8
|
config, FUNDING_ASSETS, EVM_FUNDING_ASSETS, evmRpcFor, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
|
|
9
9
|
} from './config.js';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
10
11
|
import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
|
|
11
12
|
import { withOnrampLink } from './stripeOnramp.js';
|
|
12
13
|
import { tokenBalance } from './x402.js';
|
|
@@ -35,6 +36,22 @@ import { creditBalance, quotedPrices } from './info.js';
|
|
|
35
36
|
import { priceHoldings } from './livestatus.js';
|
|
36
37
|
import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettle.js';
|
|
37
38
|
import { fetchHeaders } from './fetch.js';
|
|
39
|
+
import { attachX402Proof } from './spendProof.js';
|
|
40
|
+
|
|
41
|
+
/** Kill whatever is LISTEN on this port except this process. */
|
|
42
|
+
export function killListen(port, run = execSync) {
|
|
43
|
+
try {
|
|
44
|
+
const pids = run(`lsof -nP -iTCP:${Number(port)} -sTCP:LISTEN -t`, {
|
|
45
|
+
encoding: 'utf8', timeout: 2000,
|
|
46
|
+
}).trim().split('\n').map(Number).filter((n) => Number.isInteger(n) && n > 0 && n !== process.pid);
|
|
47
|
+
for (const pid of pids) {
|
|
48
|
+
try { run(`kill ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
|
|
49
|
+
}
|
|
50
|
+
return pids;
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
38
55
|
|
|
39
56
|
// THE SHIM IS A FACILITATOR, NOT A MIDDLEBOX.
|
|
40
57
|
//
|
|
@@ -668,7 +685,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
668
685
|
|
|
669
686
|
try {
|
|
670
687
|
const result = await client.fetch(url, init);
|
|
671
|
-
const { response, paid, receipt } = result;
|
|
688
|
+
const { response, paid, receipt, accept } = result;
|
|
672
689
|
if (paid && receipt) {
|
|
673
690
|
if (receipt.ok && typeof receipt.billedUsd === 'number') {
|
|
674
691
|
sessionSpent += receipt.billedUsd;
|
|
@@ -723,6 +740,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
723
740
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
724
741
|
}
|
|
725
742
|
if (data?.object === 'chat.completion') {
|
|
743
|
+
if (paid && receipt) {
|
|
744
|
+
attachX402Proof(data, {
|
|
745
|
+
tx: receipt.tx,
|
|
746
|
+
memo: receipt.memo || accept?.extra?.memo,
|
|
747
|
+
rail: receipt.rail,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
726
750
|
if (rKey) replayPut(rKey, data, response.headers.get('x-payment-response'));
|
|
727
751
|
if (wantsStream) { serveAsSse(res, data, response); return; }
|
|
728
752
|
const h = { 'content-type': 'application/json' };
|
|
@@ -809,9 +833,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
809
833
|
const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
|
|
810
834
|
// SELF-HEAL A TAKEN PORT. Walk up to the next free port instead of dying;
|
|
811
835
|
// the caller reads config.port back out, so every URL printed afterwards is
|
|
812
|
-
// the one we actually bound.
|
|
813
|
-
//
|
|
814
|
-
// spend across two wallets.
|
|
836
|
+
// the one we actually bound. A healthy proxy already on the port is KILLED
|
|
837
|
+
// — reusing it left a stale PayClient serving $0 after a TOKEN top-up.
|
|
815
838
|
const wanted = config.port;
|
|
816
839
|
for (let attempt = 0; ; attempt++) {
|
|
817
840
|
try {
|
|
@@ -824,13 +847,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
824
847
|
} catch (e) {
|
|
825
848
|
if (e?.code !== 'EADDRINUSE' || attempt >= 12) throw e;
|
|
826
849
|
if (attempt === 0) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
} catch { /* not ours, or wedged — take the next port */ }
|
|
850
|
+
const pids = killListen(config.port);
|
|
851
|
+
if (pids.length) {
|
|
852
|
+
say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
|
|
853
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
834
856
|
}
|
|
835
857
|
config.port += 1;
|
|
836
858
|
say(`openzoo: :${config.port - 1} busy — trying :${config.port}`);
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend-footer proof lines: explorer URL for the settle signature, decoded
|
|
3
|
+
* payment memo, one layman sentence of what that memo proves.
|
|
4
|
+
*
|
|
5
|
+
* The settle id is receipt.tx (facilitator transaction / txHash / signature).
|
|
6
|
+
* Never the SVM ownerSignature — that id is not on chain.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SOLSCAN = 'https://solscan.io/tx/';
|
|
10
|
+
const BASESCAN = 'https://basescan.org/tx/';
|
|
11
|
+
|
|
12
|
+
export function isRealTx(tx) {
|
|
13
|
+
if (typeof tx !== 'string') return false;
|
|
14
|
+
const s = tx.trim();
|
|
15
|
+
if (!s) return false;
|
|
16
|
+
if (s === 'null' || s === 'undefined' || s === '0' || s === '0x') return false;
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function collectTxs(src = {}) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
const add = (t) => {
|
|
24
|
+
if (!isRealTx(t)) return;
|
|
25
|
+
const s = String(t).trim();
|
|
26
|
+
if (seen.has(s)) return;
|
|
27
|
+
seen.add(s);
|
|
28
|
+
out.push(s);
|
|
29
|
+
};
|
|
30
|
+
add(src.tx);
|
|
31
|
+
if (Array.isArray(src.txs)) src.txs.forEach(add);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function railName(rail, network) {
|
|
36
|
+
const r = String(rail || '').toLowerCase();
|
|
37
|
+
const net = String(network || '').toLowerCase();
|
|
38
|
+
if (r === 'base' || r === 'base-sepolia' || net === 'base' || net === 'base-sepolia'
|
|
39
|
+
|| net.startsWith('eip155:8453') || /\bbase\b/.test(net)) return 'base';
|
|
40
|
+
if (r === 'solana' || net.startsWith('solana:') || net === 'solana') return 'solana';
|
|
41
|
+
if (r === 'robinhood' || net.includes('4663') || net.includes('robinhood')) return 'robinhood';
|
|
42
|
+
if (r === 'evm') return 'evm';
|
|
43
|
+
return r || '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Explorer URL for a real settle signature. Null if missing or rail unknown-and-ambiguous. */
|
|
47
|
+
export function explorerUrl(tx, { rail, network } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
if (!isRealTx(tx)) return null;
|
|
50
|
+
const sig = String(tx).trim();
|
|
51
|
+
const r = railName(rail, network);
|
|
52
|
+
if (r === 'base') return BASESCAN + sig;
|
|
53
|
+
if (r === 'solana') return SOLSCAN + sig;
|
|
54
|
+
if (r === 'robinhood' || r === 'evm') return null;
|
|
55
|
+
if (/^0x[0-9a-fA-F]{64}$/.test(sig)) return null;
|
|
56
|
+
if (!sig.startsWith('0x') && sig.length >= 32 && sig.length <= 128) return SOLSCAN + sig;
|
|
57
|
+
return null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function shortId(s, keep = 12) {
|
|
64
|
+
const t = String(s || '');
|
|
65
|
+
if (t.length <= keep * 2 + 1) return t;
|
|
66
|
+
return `${t.slice(0, keep)}…${t.slice(-keep)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseOfferSet(raw) {
|
|
70
|
+
const s = String(raw || '').trim();
|
|
71
|
+
if (!/^x402:/i.test(s)) return null;
|
|
72
|
+
const parts = s.slice(s.indexOf(':') + 1).split('/');
|
|
73
|
+
if (parts.length < 6) return null;
|
|
74
|
+
const [v, scheme, network, payTo, asset, amount] = parts;
|
|
75
|
+
const tail = parts.slice(6);
|
|
76
|
+
let resource = '';
|
|
77
|
+
let timeout = '';
|
|
78
|
+
let quote = '';
|
|
79
|
+
if (tail.length >= 2 && /^\d+$/.test(tail[tail.length - 2])) {
|
|
80
|
+
quote = tail[tail.length - 1];
|
|
81
|
+
timeout = tail[tail.length - 2];
|
|
82
|
+
resource = tail.slice(0, -2).join('/');
|
|
83
|
+
} else if (tail.length) {
|
|
84
|
+
resource = tail.join('/');
|
|
85
|
+
}
|
|
86
|
+
return { v, scheme, network, payTo, asset, amount, resource, timeout, quote };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isMerkleJson(obj) {
|
|
90
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
|
|
91
|
+
const leaf = obj.leaf || obj.merkleLeaf || obj.merkle_leaf;
|
|
92
|
+
const proof = obj.proof || obj.merkleProof || obj.merkle_proof;
|
|
93
|
+
const root = obj.root || obj.merkleRoot || obj.merkle_root;
|
|
94
|
+
if (leaf && (Array.isArray(proof) || root)) return true;
|
|
95
|
+
if (String(obj.kind || '').toLowerCase() === 'merkle' && leaf) return true;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const LABELED_LEAF = /^(?:leaf|merkle[-_]?leaf)[:\s=]+(?:0x)?([0-9a-f]{64})$/i;
|
|
100
|
+
|
|
101
|
+
function jsonMeaningful(obj) {
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
104
|
+
if (v == null) continue;
|
|
105
|
+
if (typeof v === 'string' && v.length > 120) out[k] = `${v.slice(0, 48)}…`;
|
|
106
|
+
else if (Array.isArray(v)) out[k] = v.length > 8 ? `[${v.length}]` : v;
|
|
107
|
+
else if (typeof v === 'object') out[k] = Object.keys(v).length > 12 ? '{…}' : v;
|
|
108
|
+
else out[k] = v;
|
|
109
|
+
}
|
|
110
|
+
try { return JSON.stringify(out); } catch { return String(obj); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decode a payment memo. Never claims a merkle membership proof unless the
|
|
115
|
+
* bytes actually encode a leaf/proof/root (JSON) or a labeled 32-byte leaf.
|
|
116
|
+
*/
|
|
117
|
+
export function decodeMemo(memo) {
|
|
118
|
+
try {
|
|
119
|
+
if (memo == null) return { kind: 'empty', decoded: null, proves: null };
|
|
120
|
+
const raw = Buffer.isBuffer(memo) ? memo.toString('utf8') : String(memo);
|
|
121
|
+
const trimmed = raw.trim();
|
|
122
|
+
if (!trimmed) return { kind: 'empty', decoded: null, proves: null };
|
|
123
|
+
|
|
124
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}'))
|
|
125
|
+
|| (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
126
|
+
try {
|
|
127
|
+
const obj = JSON.parse(trimmed);
|
|
128
|
+
if (isMerkleJson(obj)) {
|
|
129
|
+
const leaf = obj.leaf || obj.merkleLeaf || obj.merkle_leaf;
|
|
130
|
+
return {
|
|
131
|
+
kind: 'merkle',
|
|
132
|
+
decoded: jsonMeaningful(obj),
|
|
133
|
+
proves: `This hash (${shortId(leaf)}) is the leaf of a merkle tree; publishing it in the memo binds this payment to that leaf so anyone with the tree can verify inclusion.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
kind: 'json',
|
|
138
|
+
decoded: jsonMeaningful(obj),
|
|
139
|
+
proves: 'The payment memo is this JSON; it records what the payer attached, not a merkle membership proof unless a leaf/proof/root is present.',
|
|
140
|
+
};
|
|
141
|
+
} catch { /* not JSON */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const offer = parseOfferSet(trimmed);
|
|
145
|
+
if (offer) {
|
|
146
|
+
const who = offer.payTo || 'the listed payTo';
|
|
147
|
+
const asset = offer.asset || 'the listed asset';
|
|
148
|
+
const amt = offer.amount || '?';
|
|
149
|
+
const res = offer.resource || 'the listed resource';
|
|
150
|
+
const net = offer.network || 'the listed network';
|
|
151
|
+
return {
|
|
152
|
+
kind: 'offer_set',
|
|
153
|
+
decoded: `paid ${amt} of ${shortId(asset)} to ${shortId(who)} for ${res} on ${net}`
|
|
154
|
+
+ (offer.scheme ? ` (${offer.scheme})` : ''),
|
|
155
|
+
proves: `This memo is the x402 offer: ${amt} units of asset ${asset} were paid to ${who} for ${res} on ${net}. Anyone can check it against the mint's on-chain offer; a server 402 that disagrees is lying.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const labeled = LABELED_LEAF.exec(trimmed);
|
|
160
|
+
if (labeled) {
|
|
161
|
+
const leaf = labeled[1];
|
|
162
|
+
return {
|
|
163
|
+
kind: 'merkle',
|
|
164
|
+
decoded: `merkle leaf ${leaf}`,
|
|
165
|
+
proves: `This hash is the leaf of a merkle tree; publishing it in the memo binds this payment to that leaf so anyone with the tree can verify inclusion.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const hex = trimmed.startsWith('0x') || trimmed.startsWith('0X') ? trimmed.slice(2) : trimmed;
|
|
170
|
+
if (/^[0-9a-fA-F]{32}$/.test(hex) && !/\s/.test(trimmed)) {
|
|
171
|
+
return {
|
|
172
|
+
kind: 'nonce',
|
|
173
|
+
decoded: `uniqueness nonce ${hex.toLowerCase()}`,
|
|
174
|
+
proves: 'This is a uniqueness nonce so two identical payments in the same blockhash window are distinct transactions — it is not a secret and not a merkle membership proof.',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (/^[0-9a-fA-F]{64}$/.test(hex) && !/\s/.test(trimmed)) {
|
|
179
|
+
const leaf = hex.toLowerCase();
|
|
180
|
+
return {
|
|
181
|
+
kind: 'leaf',
|
|
182
|
+
decoded: `x402 leaf ${leaf}`,
|
|
183
|
+
proves: `x402-tokens Solana work-commitment: sha256(JSON.stringify([v, model, promptHash, gross, asset, resource])). Same Memo instruction as the token transfer; binds the payment to that quoted deal (model, prompt hash, price, mint, endpoint). The completion is not in the preimage — it did not exist at quote time. Not a merkle-tree membership proof. Preimage: https://x402-tokens.fly.dev/v1/receipts/proof?leaf=${leaf}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (/^[\x20-\x7e\u00a0-\uffff]+$/.test(trimmed)) {
|
|
188
|
+
return {
|
|
189
|
+
kind: 'utf8',
|
|
190
|
+
decoded: trimmed.length > 240 ? `${trimmed.slice(0, 237)}…` : trimmed,
|
|
191
|
+
proves: 'The payment memo is this UTF-8 string; it was not an x402 offer-set record or a structured proof.',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
kind: 'unknown',
|
|
197
|
+
decoded: `undecodable (${Buffer.byteLength(raw)} bytes)`,
|
|
198
|
+
proves: 'The memo could not be decoded; it is not claimed as a merkle proof.',
|
|
199
|
+
};
|
|
200
|
+
} catch {
|
|
201
|
+
return {
|
|
202
|
+
kind: 'unknown',
|
|
203
|
+
decoded: 'undecodable',
|
|
204
|
+
proves: 'The memo could not be decoded; it is not claimed as a merkle proof.',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Copy settle tx / memo / rail onto the JSON body's x402 object (mutates). */
|
|
210
|
+
export function attachX402Proof(data, proof = {}) {
|
|
211
|
+
try {
|
|
212
|
+
if (!data || typeof data !== 'object') return data;
|
|
213
|
+
if (!data.x402 || typeof data.x402 !== 'object') data.x402 = {};
|
|
214
|
+
const x = data.x402;
|
|
215
|
+
const txs = collectTxs({ tx: proof.tx ?? x.tx, txs: [...(Array.isArray(x.txs) ? x.txs : []), ...(Array.isArray(proof.txs) ? proof.txs : [])] });
|
|
216
|
+
if (txs.length) {
|
|
217
|
+
x.tx = txs[txs.length - 1];
|
|
218
|
+
x.txs = txs;
|
|
219
|
+
}
|
|
220
|
+
const memo = proof.memo;
|
|
221
|
+
if (typeof memo === 'string' && memo.length) x.memo = memo;
|
|
222
|
+
if (proof.rail) x.rail = proof.rail;
|
|
223
|
+
return data;
|
|
224
|
+
} catch {
|
|
225
|
+
return data;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Merge x402 proof fields across the several paid calls of one Grok Bot turn. */
|
|
230
|
+
export function mergeTurnProof(prev, data) {
|
|
231
|
+
try {
|
|
232
|
+
const incoming = (data && typeof data === 'object' && data.x402 && typeof data.x402 === 'object')
|
|
233
|
+
? data.x402
|
|
234
|
+
: {};
|
|
235
|
+
const base = prev && typeof prev === 'object' ? prev : {};
|
|
236
|
+
const txs = collectTxs({
|
|
237
|
+
tx: incoming.tx || base.tx,
|
|
238
|
+
txs: [...(Array.isArray(base.txs) ? base.txs : []), ...(Array.isArray(incoming.txs) ? incoming.txs : [])],
|
|
239
|
+
});
|
|
240
|
+
const out = { ...base, ...incoming };
|
|
241
|
+
if (txs.length) {
|
|
242
|
+
out.tx = txs[txs.length - 1];
|
|
243
|
+
out.txs = txs;
|
|
244
|
+
}
|
|
245
|
+
if (!out.memo && base.memo) out.memo = base.memo;
|
|
246
|
+
if (!out.rail && base.rail) out.rail = base.rail;
|
|
247
|
+
return out;
|
|
248
|
+
} catch {
|
|
249
|
+
return (data && data.x402) || prev || {};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Two leading newlines, then the existing spend lines, then optional
|
|
255
|
+
* tx / memo / proves. Never throws.
|
|
256
|
+
*/
|
|
257
|
+
export function formatSpendFooter({
|
|
258
|
+
billedUsd,
|
|
259
|
+
directUsd,
|
|
260
|
+
spent = 0,
|
|
261
|
+
would = 0,
|
|
262
|
+
saved = 0,
|
|
263
|
+
pct = 0,
|
|
264
|
+
balance = null,
|
|
265
|
+
x402 = {},
|
|
266
|
+
tx,
|
|
267
|
+
txs,
|
|
268
|
+
memo,
|
|
269
|
+
rail,
|
|
270
|
+
network,
|
|
271
|
+
} = {}) {
|
|
272
|
+
try {
|
|
273
|
+
const lines = ['', ''];
|
|
274
|
+
const x = (x402 && typeof x402 === 'object') ? x402 : {};
|
|
275
|
+
if (billedUsd != null && Number.isFinite(Number(billedUsd))) {
|
|
276
|
+
lines.push(`this call $${Number(billedUsd).toFixed(6)} · OpenRouter $${Number(directUsd || 0).toFixed(6)}`);
|
|
277
|
+
}
|
|
278
|
+
const spentN = Number.isFinite(Number(spent)) ? Number(spent) : 0;
|
|
279
|
+
const wouldN = Number.isFinite(Number(would)) ? Number(would) : 0;
|
|
280
|
+
const savedN = Number.isFinite(Number(saved)) ? Number(saved) : 0;
|
|
281
|
+
const pctN = Number.isFinite(Number(pct)) ? Number(pct) : 0;
|
|
282
|
+
const bal = balance != null && Number.isFinite(Number(balance)) ? Number(balance) : null;
|
|
283
|
+
const balTxt = bal != null ? ` · balance $${bal.toFixed(2)}` : '';
|
|
284
|
+
lines.push(`spent $${spentN.toFixed(4)}${balTxt} · OpenRouter would $${wouldN.toFixed(4)} · saved $${savedN.toFixed(4)} (${pctN.toFixed(0)}%)`);
|
|
285
|
+
|
|
286
|
+
const sigs = collectTxs({ tx: tx ?? x.tx, txs: txs ?? x.txs });
|
|
287
|
+
const r = rail || x.rail;
|
|
288
|
+
const net = network || x.network;
|
|
289
|
+
for (const sig of sigs) {
|
|
290
|
+
const url = explorerUrl(sig, { rail: r, network: net });
|
|
291
|
+
if (url) lines.push(`tx ${url}`);
|
|
292
|
+
}
|
|
293
|
+
const mem = memo ?? x.memo;
|
|
294
|
+
if (mem != null && String(mem).length) {
|
|
295
|
+
const d = decodeMemo(mem);
|
|
296
|
+
if (d.decoded) lines.push(`memo ${d.decoded}`);
|
|
297
|
+
if (d.proves) lines.push(`proves ${d.proves}`);
|
|
298
|
+
}
|
|
299
|
+
const body = lines.filter((l) => l !== '').join('\n');
|
|
300
|
+
const billedN = Number(billedUsd);
|
|
301
|
+
const tagN = Number.isFinite(billedN) && billedN > 0.00005 ? billedN : spentN;
|
|
302
|
+
const tag = `$${tagN.toFixed(4)}`;
|
|
303
|
+
return `\n\n::oz-spend::${tag}\n${body}`;
|
|
304
|
+
} catch {
|
|
305
|
+
return '\n\n';
|
|
306
|
+
}
|
|
307
|
+
}
|
package/lib/x402.js
CHANGED
|
@@ -309,10 +309,11 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
|
|
|
309
309
|
// The seller's memo when they set one — some price it into the settlement —
|
|
310
310
|
// else a random 16-byte hex nonce, which is what makes two concurrent
|
|
311
311
|
// identical payments distinct messages.
|
|
312
|
+
const memo = String(accept.extra?.memo || crypto.randomBytes(16).toString('hex'));
|
|
312
313
|
tx.add(new TransactionInstruction({
|
|
313
314
|
keys: [],
|
|
314
315
|
programId: MEMO_PROGRAM_ID,
|
|
315
|
-
data: Buffer.from(
|
|
316
|
+
data: Buffer.from(memo, 'utf8'),
|
|
316
317
|
}));
|
|
317
318
|
if (tx.instructions.length < 3 || tx.instructions.length > 7) {
|
|
318
319
|
throw new Error(`x402 svm payment has ${tx.instructions.length} instructions, outside the required 3..7`);
|
|
@@ -337,6 +338,7 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
|
|
|
337
338
|
amount,
|
|
338
339
|
source,
|
|
339
340
|
dest,
|
|
341
|
+
memo,
|
|
340
342
|
};
|
|
341
343
|
}
|
|
342
344
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.39",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|