openzoo 0.9.1 → 0.9.3

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
@@ -3,7 +3,7 @@ import { getAssociatedTokenAddressSync } from '@solana/spl-token';
3
3
  import { config, fundingLine } from './config.js';
4
4
  import { loadOrCreateWallet } from './wallet.js';
5
5
  import {
6
- parse402, pickAccept, railOf, buildPaymentOnline, tokenBalance,
6
+ parse402, orderAccepts, railOf, buildPaymentOnline, tokenBalance,
7
7
  receiptLine, decodeSettleHeader,
8
8
  } from './x402.js';
9
9
  import { buildEvmPayment, evmTokenBalance } from './evm.js';
@@ -180,15 +180,41 @@ export class PayClient {
180
180
 
181
181
  const quote = parse402(await first.json());
182
182
  // config.rail (OPENZOO_RAIL) steers every front — proxy, demo, MCP — since
183
- // they all pay through this one call site.
184
- const accept = pickAccept(quote, config.token, { allowRH: this.allowRH, forceRail: config.rail });
185
- const billedUsd = Number(accept?.extra?.billedUsd ?? NaN);
186
- if (Number.isFinite(billedUsd) && billedUsd > config.maxUsdPerCall) {
187
- throw new QuoteTooHighError(billedUsd, quote);
188
- }
189
-
183
+ // they all pay through this one call site. The wallet pays with whatever
184
+ // it HOLDS: every offered row is tried best-first, and only when NONE is
185
+ // affordable does the call fail — never because the first-choice asset
186
+ // alone ran dry while another funded one sat in the wallet.
187
+ const candidates = orderAccepts(quote, config.token, { allowRH: this.allowRH, forceRail: config.rail });
190
188
  onStage?.('quoted');
191
- const payment = await this.buildPaymentFor(accept, onStage);
189
+ let accept = null;
190
+ let payment = null;
191
+ const fundErrs = [];
192
+ let tooHigh = null;
193
+ for (const cand of candidates) {
194
+ const billedUsd = Number(cand?.extra?.billedUsd ?? NaN);
195
+ if (Number.isFinite(billedUsd) && billedUsd > config.maxUsdPerCall) {
196
+ tooHigh = tooHigh || new QuoteTooHighError(billedUsd, quote);
197
+ continue;
198
+ }
199
+ try {
200
+ payment = await this.buildPaymentFor(cand, onStage);
201
+ accept = cand;
202
+ break;
203
+ } catch (e) {
204
+ const funding = e instanceof UnderfundedError
205
+ || e?.name === 'UnderlyingShortError' || e?.name === 'NeedsGasError';
206
+ if (!funding) throw e;
207
+ fundErrs.push({ sym: cand?.extra?.symbol || cand?.asset, err: e });
208
+ }
209
+ }
210
+ if (!payment) {
211
+ if (!fundErrs.length && tooHigh) throw tooHigh;
212
+ if (fundErrs.length === 1) throw fundErrs[0].err;
213
+ throw new UnderfundedError(candidates[0], null, this.address, {
214
+ line: `No offered payment row is affordable from this wallet (tried ${fundErrs.length}):\n`
215
+ + fundErrs.map(({ sym, err }) => ` · ${sym}: ${err.message.replace(/^openzoo wallet underfunded: /, '')}`).join('\n'),
216
+ });
217
+ }
192
218
  onStage?.('paying');
193
219
  const response = await fetch(url, {
194
220
  ...init,
package/lib/proxy.js CHANGED
@@ -2,10 +2,11 @@ import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
3
  import { Readable } from 'node:stream';
4
4
  import {
5
- config, FUNDING_ASSETS, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
5
+ config, FUNDING_ASSETS, EVM_FUNDING_ASSETS, evmRpcFor, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
6
6
  } from './config.js';
7
7
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
8
8
  import { tokenBalance } from './x402.js';
9
+ import { evmTokenBalance } from './evm.js';
9
10
  import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
10
11
  import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
11
12
  import { forgetContext } from './contexts.js';
@@ -56,6 +57,42 @@ function jsonErr(res, status, message, extraFields = {}) {
56
57
 
57
58
  const mb = (n) => (n / 1048576).toFixed(1);
58
59
 
60
+ /**
61
+ * Every fundable balance across all three chains, for the startup line and
62
+ * the live refresh. Each read is independent and advisory — one lagging RPC
63
+ * drops its entry rather than blanking the whole line.
64
+ */
65
+ async function snapshotBalances(client) {
66
+ const out = [];
67
+ try {
68
+ const bals = await Promise.all(
69
+ FUNDING_ASSETS.map((a) => tokenBalance(client.connection, client.keypair.publicKey, a.mint)),
70
+ );
71
+ FUNDING_ASSETS.forEach((a, i) => out.push({ symbol: a.symbol, ui: Number(bals[i].ui ?? 0), chain: 'solana' }));
72
+ } catch { /* Solana RPC hiccup — EVM entries still report */ }
73
+ const owner = client.evmAddress;
74
+ if (owner) {
75
+ await Promise.all(Object.entries(EVM_FUNDING_ASSETS).flatMap(([rail, assets]) => assets.map(async (a) => {
76
+ try {
77
+ const raw = await evmTokenBalance({ rpcUrl: evmRpcFor(rail), token: a.address, owner });
78
+ out.push({ symbol: a.symbol, ui: Number(raw) / 10 ** a.decimals, chain: rail });
79
+ } catch { /* advisory */ }
80
+ })));
81
+ }
82
+ // Parallel reads land in racy order; sort so the printed line is stable
83
+ // and diffs against the previous snapshot read cleanly.
84
+ const rank = { solana: 0, base: 1, robinhood: 2 };
85
+ return out.sort((a, b) => (rank[a.chain] ?? 9) - (rank[b.chain] ?? 9) || a.symbol.localeCompare(b.symbol));
86
+ }
87
+
88
+ /** Solana entries always show; EVM entries only once they hold something. */
89
+ function balanceLine(snap) {
90
+ return snap
91
+ .filter((b) => b.chain === 'solana' || b.ui > 0)
92
+ .map((b) => `${b.ui} ${b.symbol}${b.chain !== 'solana' ? ` (${b.chain})` : ''}`)
93
+ .join(' · ');
94
+ }
95
+
59
96
  /**
60
97
  * The zoo answers chat completions as ONE JSON object (it settles payment
61
98
  * before serving — there is nothing to stream until generation is done).
@@ -175,6 +212,17 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
175
212
  const log = silent ? () => {} : (...a) => console.log(...a);
176
213
  let sessionSpent = 0;
177
214
  let tunnelSpent = 0;
215
+ // Live balance refresh state — the real implementation is assigned in the
216
+ // banner section below; the handler only ever calls scheduleRefresh().
217
+ let lastSnap = null;
218
+ let refreshBalances = async () => {};
219
+ let refreshPending = false;
220
+ const scheduleRefresh = (ms) => {
221
+ if (silent || refreshPending) return;
222
+ refreshPending = true;
223
+ const t = setTimeout(async () => { refreshPending = false; await refreshBalances(); }, ms);
224
+ t.unref?.();
225
+ };
178
226
  // Set once cloudflared is up (see below). Gating keys off the REQUEST's
179
227
  // origin, not off whether the URL exists yet, so there is no startup window
180
228
  // where public traffic slips through ungated.
@@ -319,6 +367,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
319
367
  if (requireToken) log(`${line} · session $${sessionSpent.toFixed(6)}`);
320
368
  else if (viaTunnel) log(`${line} · public-url session $${tunnelSpent.toFixed(6)}`);
321
369
  else log(line);
370
+ scheduleRefresh(4000); // settlement lands on-chain in a few seconds
322
371
  }
323
372
  // Chat completions come back as one JSON object (settle-before-serve).
324
373
  // Cache it against retries, and if the harness asked to stream, honour
@@ -366,15 +415,39 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
366
415
  console.log(`wallet (fund me) · solana: ${client.address}`);
367
416
  if (client.evmAddress) console.log(`wallet (fund me) · evm (base / robinhood): ${client.evmAddress}`);
368
417
  try {
369
- const bals = await Promise.all(
370
- FUNDING_ASSETS.map((a) => tokenBalance(client.connection, client.keypair.publicKey, a.mint)),
371
- );
372
- const parts = FUNDING_ASSETS.map((a, i) => `${bals[i].ui ?? 0} ${a.symbol}`);
373
- console.log(`balance: ${parts.join(' · ')}`);
374
- if (!bals.some((b) => b.raw)) {
418
+ lastSnap = await snapshotBalances(client);
419
+ console.log(`balance: ${balanceLine(lastSnap) || '(no RPC reachable — advisory only)'}`);
420
+ if (!lastSnap.some((b) => b.ui > 0)) {
375
421
  console.log(`fund it: ${fundingLine('the address above')} — a few cents goes a long way.`);
376
422
  }
377
423
  } catch { /* RPC hiccup: balance is advisory */ }
424
+ // LIVE REFRESH: the startup line goes stale the moment a call settles or
425
+ // the user funds mid-session. Poll on an interval (and shortly after each
426
+ // paid call), print ONLY on change, and call out arrivals explicitly so
427
+ // "did my top-up land?" answers itself in the running log.
428
+ refreshBalances = async () => {
429
+ try {
430
+ const snap = await snapshotBalances(client);
431
+ if (!snap.length) return;
432
+ const prev = new Map((lastSnap || []).map((b) => [`${b.chain}:${b.symbol}`, b.ui]));
433
+ const changed = snap.some((b) => Math.abs((prev.get(`${b.chain}:${b.symbol}`) ?? 0) - b.ui) > 1e-9)
434
+ || snap.length !== (lastSnap || []).length;
435
+ if (!changed) return;
436
+ if (lastSnap) {
437
+ for (const b of snap) {
438
+ const gain = b.ui - (prev.get(`${b.chain}:${b.symbol}`) ?? 0);
439
+ if (gain > 1e-9) console.log(`funding arrived: +${gain.toFixed(6)} ${b.symbol}${b.chain !== 'solana' ? ` (${b.chain})` : ''}`);
440
+ }
441
+ }
442
+ lastSnap = snap;
443
+ console.log(`balance: ${balanceLine(snap)}`);
444
+ } catch { /* advisory — never noisy on RPC trouble */ }
445
+ };
446
+ const pollSecs = Number(process.env.OPENZOO_BALANCE_POLL_SECS ?? 45);
447
+ if (pollSecs > 0) {
448
+ const timer = setInterval(refreshBalances, pollSecs * 1000);
449
+ timer.unref?.();
450
+ }
378
451
  // Which rails the zoo will actually settle right now, straight off a live
379
452
  // 402 — so nobody funds a lane the resource is not currently offering.
380
453
  try {
package/lib/x402.js CHANGED
@@ -74,8 +74,20 @@ export function evmChainId(network) {
74
74
  * but RH stays opt-in for DEFAULT selection because its settlement asset has
75
75
  * no auto-conversion path here.
76
76
  */
77
- export function pickAccept(body, preferredSymbol, { allowRH = false, forceRail = null } = {}) {
77
+ /**
78
+ * ALL payable rows from a 402, best-first. The wallet pays with whatever it
79
+ * HOLDS — the caller walks this list and takes the first affordable row, so
80
+ * a wallet rich in TOKEN but short of USDC pays the TOKEN row instead of
81
+ * erroring on the USDC one. Preference order within the list: the preferred
82
+ * symbol, then the rest of Solana (sponsored fees), then Base, then Robinhood
83
+ * (gated — paying there costs the wallet its own gas).
84
+ */
85
+ export function orderAccepts(body, preferredSymbol, { allowRH = false, forceRail = null } = {}) {
78
86
  const rows = parse402(body).accepts.filter((a) => a?.scheme === 'exact');
87
+ const bySym = (list) => [
88
+ ...list.filter((a) => a?.extra?.symbol === preferredSymbol),
89
+ ...list.filter((a) => a?.extra?.symbol !== preferredSymbol),
90
+ ];
79
91
  if (forceRail) {
80
92
  const want = String(forceRail).toLowerCase();
81
93
  if (!['solana', 'base', 'robinhood', 'evm'].includes(want)) {
@@ -88,19 +100,24 @@ export function pickAccept(body, preferredSymbol, { allowRH = false, forceRail =
88
100
  `OPENZOO_RAIL=${want} but the live 402 offers no ${want} rail (offered: ${offered.join(', ') || 'none'})`,
89
101
  );
90
102
  }
91
- return match.find((a) => a?.extra?.symbol === preferredSymbol) || match[0];
103
+ return bySym(match);
104
+ }
105
+ const out = [
106
+ ...bySym(rows.filter((a) => railOf(a) === 'solana')),
107
+ ...rows.filter((a) => railOf(a) === 'base'),
108
+ ...rows.filter((a) => railOf(a) === 'evm'),
109
+ ...(allowRH ? rows.filter((a) => railOf(a) === 'robinhood') : []),
110
+ ];
111
+ if (!out.length) {
112
+ throw new Error(rows.some((a) => railOf(a) === 'robinhood')
113
+ ? 'only Robinhood Chain rails offered — set OPENZOO_ENABLE_RH=1 or OPENZOO_RAIL=robinhood to use them (the rail settles; you must hold its settlement asset, see https://x402.accrue.fund/start)'
114
+ : 'no payable rail in 402 accepts[]');
92
115
  }
93
- const sol = rows.filter((a) => railOf(a) === 'solana');
94
- if (sol.length) return sol.find((a) => a?.extra?.symbol === preferredSymbol) || sol[0];
95
- const base = rows.filter((a) => railOf(a) === 'base');
96
- if (base.length) return base[0];
97
- const evm = rows.filter((a) => railOf(a) === 'evm');
98
- if (evm.length) return evm[0];
99
- const rh = rows.filter((a) => railOf(a) === 'robinhood');
100
- if (allowRH && rh.length) return rh[0];
101
- throw new Error(rh.length
102
- ? 'only Robinhood Chain rails offered — set OPENZOO_ENABLE_RH=1 or OPENZOO_RAIL=robinhood to use them (the rail settles; you must hold its settlement asset, see https://x402.accrue.fund/start)'
103
- : 'no payable rail in 402 accepts[]');
116
+ return out;
117
+ }
118
+
119
+ export function pickAccept(body, preferredSymbol, opts = {}) {
120
+ return orderAccepts(body, preferredSymbol, opts)[0];
104
121
  }
105
122
 
106
123
  const mintCache = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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",