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/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 — checked semi-frequently in the background, not per call.
56
+ * BALANCE CACHE — positive balances SWR for a few seconds; zeros are live.
57
57
  *
58
- * MEASURED: a per-request balance probe put a mainnet round trip on the critical
59
- * path of EVERY inference — 4.52s of the 7s a one-line completion took, most of
60
- * it the since-deleted conversion probe a short row triggered. Balances do not
61
- * move at request rate; a wallet spending $0.00004 a call has the same balance
62
- * it had a minute ago.
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 || 60_000);
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 fresh = hit && !force && (Date.now() - hit.at) < BALANCE_TTL_MS;
79
- if (hit && !force) {
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; }); // keep the last good value
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 || 120_000);
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
- let bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset);
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. EXCEPTION: if the thing already on the port is
813
- // a HEALTHY openzoo proxy, reuse it rather than starting a rival that splits
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
- try {
828
- const probe = await fetch(`http://127.0.0.1:${config.port}/v1/models`, { signal: AbortSignal.timeout(2500) });
829
- if (probe.ok) {
830
- say(`openzoo: a healthy proxy is already on :${config.port} — reusing it`);
831
- return { server: null, client, reused: true, port: config.port, spent: () => 0, publicUrl: null, tunnelToken: null, tunnelError: null };
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(accept.extra?.memo || crypto.randomBytes(16).toString('hex'), 'utf8'),
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.37",
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",