openzoo 0.51.7 → 0.51.8

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.
@@ -1,6 +1,7 @@
1
1
  /** Read-only savings overlay for the OpenZoo ChatGPT profile. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import os from 'node:os';
4
5
  import { spawn } from 'node:child_process';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { cdpSession, listCdpPages } from './ozSpendChip.js';
@@ -149,7 +150,27 @@ export async function watchChatGptSpend({ profile, proxyPort, fetchImpl = fetch,
149
150
  } finally { for (const session of sessions.values()) session.close(); }
150
151
  }
151
152
 
153
+ /** A current launcher supplies a native pill even when the chat app disables debugging. */
154
+ export function nativeSavingsLauncher({ platform = process.platform, home = os.homedir(), env = process.env, exists = fs.existsSync } = {}) {
155
+ const roots = platform === 'darwin'
156
+ ? ['/Applications/OpenZoo Launcher.app/Contents', path.join(home, 'Applications/OpenZoo Launcher.app/Contents')]
157
+ : platform === 'win32' ? [path.join(env.LOCALAPPDATA || home, 'Programs/openzoo-launcher'), path.join(env.LOCALAPPDATA || home, 'Programs/OpenZoo Launcher')] : [];
158
+ for (const root of roots) {
159
+ const resources = path.join(root, platform === 'darwin' ? 'Resources' : 'resources');
160
+ const executable = platform === 'darwin' ? path.join(root, 'MacOS/OpenZoo Launcher') : path.join(root, 'OpenZoo Launcher.exe');
161
+ if (exists(path.join(resources, 'openzoo-overlay.json')) && exists(executable)) return executable;
162
+ }
163
+ return null;
164
+ }
165
+
152
166
  export async function startChatGptSpend({ profile, proxyPort, spawnImpl = spawn } = {}) {
167
+ const native = nativeSavingsLauncher();
168
+ if (native && proxyPort === 8402) {
169
+ const child = spawnImpl(native, ['--savings-only'], { detached: true, stdio: 'ignore' });
170
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
171
+ child.unref();
172
+ return;
173
+ }
153
174
  fs.mkdirSync(profile, { recursive: true });
154
175
  const fd = fs.openSync(path.join(profile, 'openzoo-savings.log'), 'a', 0o600);
155
176
  try {
package/lib/evm.js CHANGED
@@ -9,7 +9,7 @@ const ERC20_ABI = [
9
9
 
10
10
  const evmClients = new Map();
11
11
  function clientFor(rpcUrl) {
12
- if (!evmClients.has(rpcUrl)) evmClients.set(rpcUrl, createPublicClient({ transport: http(rpcUrl) }));
12
+ if (!evmClients.has(rpcUrl)) evmClients.set(rpcUrl, createPublicClient({ transport: http(rpcUrl, { timeout: 4000, retryCount: 0 }) }));
13
13
  return evmClients.get(rpcUrl);
14
14
  }
15
15
 
@@ -0,0 +1,17 @@
1
+ /** Coalesce live reads, retain unknowns, and never cache an empty balance for a minute. */
2
+ export function liveBalance(read, { ttlMs = 3000, now = Date.now } = {}) {
3
+ let value = null, at = 0, inflight;
4
+ return {
5
+ get value() { return value; },
6
+ get checkedAt() { return at || null; },
7
+ async refresh(force = false) {
8
+ if (inflight) return inflight;
9
+ if (!force && at && now() - at < ttlMs) return value;
10
+ inflight = Promise.resolve().then(read).then(next => {
11
+ if (typeof next !== 'number' || !Number.isFinite(next) || next < 0) throw new Error('Balance unavailable');
12
+ value = next; at = now(); return value;
13
+ }).catch(() => value).finally(() => { inflight = null; });
14
+ return inflight;
15
+ },
16
+ };
17
+ }
package/lib/info.js CHANGED
@@ -259,14 +259,12 @@ export { CREDIT_TIMEOUT_MS };
259
259
 
260
260
  export async function creditBalance() {
261
261
  const { withNamespace } = await import('./namespace.js');
262
- try {
263
- const r = await fetch(`${config.apiBase}/v1/credits`, {
264
- headers: withNamespace({}),
265
- signal: AbortSignal.timeout(CREDIT_TIMEOUT_MS),
266
- });
267
- const j = await r.json();
268
- return Number(j.balanceUsd) || 0;
269
- } catch {
270
- return 0;
271
- }
262
+ const r = await fetch(`${config.apiBase}/v1/credits`, {
263
+ headers: withNamespace({}), cache: 'no-store',
264
+ signal: AbortSignal.timeout(CREDIT_TIMEOUT_MS),
265
+ });
266
+ if (!r.ok) throw new Error('Credit balance is temporarily unavailable');
267
+ const j = await r.json();
268
+ if (typeof j.balanceUsd !== 'number' || !Number.isFinite(j.balanceUsd) || j.balanceUsd < 0) throw new Error('Credit balance is temporarily unavailable');
269
+ return j.balanceUsd;
272
270
  }
package/lib/pay.js CHANGED
@@ -27,6 +27,10 @@ export class QuoteTooHighError extends Error {
27
27
  }
28
28
  }
29
29
 
30
+ export class BalanceUnavailableError extends Error {
31
+ constructor() { super('We couldn’t check your balance right now. Please try again. You don’t need to add funds again.'); }
32
+ }
33
+
30
34
  export class UnderfundedError extends Error {
31
35
  constructor(accept, usdcUi, address, { line } = {}) {
32
36
  const usd = Number(accept?.extra?.billedUsd);
@@ -149,14 +153,10 @@ export function chainName(network) {
149
153
 
150
154
  const memoKey = (a) => `${a?.network || ''}|${a?.asset || ''}`;
151
155
 
152
- /** lastGood first, known-underfunded last (never dropped — only deprioritised). */
153
- export function orderCandidatesByMemory(cands, now = Date.now()) {
154
- const skip = (c) => (underfundedUntil.get(memoKey(c)) ?? 0) > now;
156
+ /** Prefer a confirmed successful payer; recheck other balances on every request. */
157
+ export function orderCandidatesByMemory(cands) {
155
158
  const good = (c) => lastGoodAsset && memoKey(c) === lastGoodAsset;
156
- // Stable partition: proven payer, then untried, then recently-underfunded. The
157
- // last group is KEPT so a wallet funded seconds ago is still reachable in the
158
- // same call if nothing else works.
159
- return [...cands].sort((a, b) => (good(b) - good(a)) || (skip(a) - skip(b)));
159
+ return [...cands].sort((a, b) => good(b) - good(a));
160
160
  }
161
161
 
162
162
  /** Test seam: forget everything the rail memory learned. */
@@ -259,7 +259,7 @@ export class PayClient {
259
259
  const rail = railOf(accept);
260
260
  if (rail === 'solana') {
261
261
  const need = BigInt(accept.maxAmountRequired);
262
- const bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true });
262
+ const bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true }).catch(() => { throw new BalanceUnavailableError(); });
263
263
  // SHORT IS SHORT. The 402 quotes the raw native mint, so there is nothing
264
264
  // to convert and no pool to walk: the wallet either holds the asset or it
265
265
  // does not. This branch used to run resolvePool + poolState + a wrap
@@ -302,7 +302,7 @@ export class PayClient {
302
302
  });
303
303
  }
304
304
  if (owner) {
305
- const bal = await evmTokenBalance({ rpcUrl, token: accept.asset, owner }).catch(() => null);
305
+ const bal = await evmTokenBalance({ rpcUrl, token: accept.asset, owner }).catch(() => { throw new BalanceUnavailableError(); });
306
306
  if (bal !== null && bal < BigInt(accept.maxAmountRequired)) {
307
307
  // Robinhood used to quote an ERC-4626 vault twin here and the shim
308
308
  // ran approve+deposit to mint it. The gateway now quotes canonical
@@ -377,6 +377,7 @@ export class PayClient {
377
377
  let accept = null;
378
378
  let payment = null;
379
379
  const fundErrs = [];
380
+ let balanceError = null;
380
381
  let tooHigh = null;
381
382
  for (const cand of candidates) {
382
383
  const billedUsd = Number(cand?.extra?.billedUsd ?? NaN);
@@ -388,13 +389,13 @@ export class PayClient {
388
389
  payment = await this.buildPaymentFor(cand, onStage, quote);
389
390
  if (fundErrs.length) railNote(`paying on ${railLabel(cand)}`);
390
391
  accept = cand;
391
- lastGoodAsset = memoKey(cand);
392
392
  underfundedUntil.delete(memoKey(cand));
393
393
  if (railOf(cand) === 'solana') {
394
394
  try { debitCachedBalance(this.keypair.publicKey, cand.asset, BigInt(cand.maxAmountRequired)); } catch { /* advisory */ }
395
395
  }
396
396
  break;
397
397
  } catch (e) {
398
+ if (e instanceof BalanceUnavailableError) { balanceError = e; continue; }
398
399
  if (!(e instanceof UnderfundedError)) throw e;
399
400
  // Remember it so the next call does not re-pay the discovery cost.
400
401
  underfundedUntil.set(memoKey(cand), Date.now() + RAIL_MEMO_MS);
@@ -411,6 +412,7 @@ export class PayClient {
411
412
  }
412
413
  }
413
414
  if (!payment) {
415
+ if (balanceError) throw balanceError;
414
416
  if (!fundErrs.length && tooHigh) throw tooHigh;
415
417
  if (fundErrs.length === 1) throw fundErrs[0].err;
416
418
  throw new UnderfundedError(candidates[0], null, this.address, {
@@ -452,11 +454,11 @@ export class PayClient {
452
454
  });
453
455
  if (again.status !== 402) {
454
456
  accept = cand; payment = retry; response = again;
455
- lastGoodAsset = memoKey(cand);
457
+ if (again.ok) lastGoodAsset = memoKey(cand);
456
458
  break;
457
459
  }
458
460
  } catch (e) {
459
- if (!(e instanceof UnderfundedError)) throw e;
461
+ if (!(e instanceof UnderfundedError) && !(e instanceof BalanceUnavailableError)) throw e;
460
462
  }
461
463
  }
462
464
  }
@@ -513,6 +515,9 @@ export class PayClient {
513
515
  this._rebuilt = false;
514
516
  }
515
517
  }
518
+ if (response.ok) lastGoodAsset = memoKey(accept);
519
+ else if (lastGoodAsset === memoKey(accept)) lastGoodAsset = null;
520
+
516
521
  // NEVER PRESENT OUR OWN SIGNATURE AS THE TRANSACTION.
517
522
  //
518
523
  // This fell back to `{ signature: payment.ownerSignature }`, and on Solana
@@ -0,0 +1,15 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** Report a failed turn through the client's response stream, not transport-error prose. */
3
+ export function paymentError(res, req, init, status, message) {
4
+ let body = {};
5
+ try { body = JSON.parse(String(init?.body || '{}')); } catch {}
6
+ if (/\/responses(?:\?|$)/.test(req.url || '') && body.stream === true) {
7
+ const response = { id: `resp_${randomUUID()}`, object: 'response', created_at: Math.floor(Date.now()/1000), status: 'failed',
8
+ error: { code: 'server_error', message }, output: [], usage: null, model: body.model || '', incomplete_details: null };
9
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
10
+ res.end(`event: response.failed\ndata: ${JSON.stringify({ type: 'response.failed', sequence_number: 0, response })}\n\n`);
11
+ return;
12
+ }
13
+ res.writeHead(status, { 'content-type': 'application/json' });
14
+ res.end(JSON.stringify({ error: { message } }));
15
+ }
package/lib/proxy.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { liveBalance } from './funding-status.js';
2
+ import { paymentError } from './payment-error.js';
1
3
  import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
2
4
  import os from 'node:os';
3
5
  import path from 'node:path';
@@ -8,8 +10,8 @@ import {
8
10
  config, FUNDING_ASSETS, EVM_FUNDING_ASSETS, evmRpcFor, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
9
11
  } from './config.js';
10
12
  import { execSync } from 'node:child_process';
11
- import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
12
- import { withOnrampLink, settleFailCopy } from './stripeOnramp.js';
13
+ import { PayClient, QuoteTooHighError, UnderfundedError, BalanceUnavailableError, resetBalanceCache, resetRailMemory } from './pay.js';
14
+ import { withOnrampLink, settleFailCopy, whopFundBlurb } from './stripeOnramp.js';
13
15
  import { tokenBalance } from './x402.js';
14
16
  import { evmTokenBalance } from './evm.js';
15
17
  import { autoContext } from './autobind.js';
@@ -451,7 +453,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
451
453
  let refreshBalances = async () => {};
452
454
  let refreshPending = false;
453
455
  const scheduleRefresh = (ms) => {
454
- if (silent || refreshPending) return;
456
+ if (refreshPending) return;
455
457
  refreshPending = true;
456
458
  const t = setTimeout(async () => { refreshPending = false; await refreshBalances(); }, ms);
457
459
  t.unref?.();
@@ -471,11 +473,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
471
473
  // billed for ONLY those calls whose real cost we learned — the honest
472
474
  // numerator for markupX.
473
475
  let billedWithActual = 0;
474
- // CREDIT, CACHED. Refreshed at most every 20s and served stale in between —
476
+ // CREDIT, CACHED. Refreshed every few seconds and served stale in between —
475
477
  // a status line must never add latency to the thing it is describing.
476
478
  let creditUsd = null;
477
479
  let creditAt = 0;
478
- let creditInflight = null;
479
480
  let lastPrices = {};
480
481
  let pricesAt = 0;
481
482
  // WALLET BALANCE, CACHED LIKE CREDIT.
@@ -490,40 +491,38 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
490
491
  // behind it — the same contract refreshCredit() keeps.
491
492
  let walletUsd = null;
492
493
  let walletAt = 0;
493
- let walletInflight = null;
494
+ const walletStatus = liveBalance(async () => {
495
+ const snap = await snapshotBalances(client);
496
+ if (!snap.length) throw new Error('Balances unavailable');
497
+ const previous = new Map((lastSnap || []).map(b => [`${b.chain}:${b.symbol}`, b]));
498
+ for (const b of snap) {
499
+ const old = previous.get(`${b.chain}:${b.symbol}`);
500
+ if (b.ui > (old?.ui ?? 0)) { resetBalanceCache(); resetRailMemory(); }
501
+ previous.set(`${b.chain}:${b.symbol}`, b);
502
+ }
503
+ lastSnap = [...previous.values()];
504
+ return priceHoldings(lastSnap, lastPrices).chainUsd;
505
+ });
506
+ const creditStatus = liveBalance(creditBalance);
494
507
  const refreshWallet = async (force = false) => {
495
- if (!force && Date.now() - walletAt < 60000 && walletUsd != null) return walletUsd;
496
- if (walletInflight) return walletInflight;
497
- walletInflight = (async () => {
498
- try {
499
- const { affordableUsd } = await import('./info.js');
500
- const v = await affordableUsd();
501
- if (Number.isFinite(v)) { walletUsd = v; walletAt = Date.now(); }
502
- } catch { /* keep last known */ }
503
- walletInflight = null;
504
- return walletUsd;
505
- })();
506
- return walletInflight;
508
+ walletUsd = await walletStatus.refresh(force);
509
+ walletAt = walletStatus.checkedAt;
510
+ return walletUsd;
507
511
  };
508
512
  const refreshCredit = async (force = false) => {
509
- if (!force && Date.now() - creditAt < 20000 && creditUsd != null) return creditUsd;
510
- if (creditInflight) return creditInflight;
511
- creditInflight = (async () => {
512
- try {
513
- creditUsd = await creditBalance();
514
- creditAt = Date.now();
515
- } catch { /* keep last known */ }
516
- creditInflight = null;
517
- return creditUsd;
518
- })();
519
- return creditInflight;
513
+ creditUsd = await creditStatus.refresh(force);
514
+ creditAt = creditStatus.checkedAt;
515
+ return creditUsd;
520
516
  };
517
+ let pricesInflight = null;
521
518
  const refreshPrices = async () => {
522
- if (Date.now() - pricesAt < 60000 && Object.keys(lastPrices).length) return lastPrices;
523
- try {
524
- lastPrices = await quotedPrices();
519
+ if (Date.now() - pricesAt < 60000) return lastPrices;
520
+ if (!pricesInflight) {
525
521
  pricesAt = Date.now();
526
- } catch { /* keep last */ }
522
+ pricesInflight = quotedPrices().then(prices => { lastPrices = prices; })
523
+ .catch(() => {}).finally(() => { pricesInflight = null; });
524
+ }
525
+ await pricesInflight;
527
526
  return lastPrices;
528
527
  };
529
528
  const walletMoney = () => priceHoldings(lastSnap || [], lastPrices);
@@ -570,6 +569,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
570
569
  return;
571
570
  }
572
571
 
572
+ if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/funding') {
573
+ await Promise.all([refreshCredit(true), refreshWallet(true), refreshBalances()]);
574
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
575
+ res.end(JSON.stringify({ creditUsd, walletUsd, creditCheckedAt: creditAt || null, walletCheckedAt: walletAt || null }));
576
+ return;
577
+ }
578
+
573
579
  if ((req.url || '').split('?')[0] === '/mcp') {
574
580
  try {
575
581
  const { handleMcpRequest } = await import('./mcphttp.js');
@@ -1068,21 +1074,24 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1068
1074
  // USDC on Base — so every 402 after a payment carries all three, not
1069
1075
  // only the ones the gateway managed to classify as insufficient funds.
1070
1076
  msg = await withOnrampLink(msg, { solana: client.address, evm: client.evmAddress, usd, code: copy.code, force: true });
1071
- log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
1072
- jsonErr(res, paid ? copy.status : 402, msg);
1077
+ log(/At checkout/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
1078
+ log(`payment diagnostic: ${copy.reason}`);
1079
+ paymentError(res, req, init, paid ? copy.status : 402, msg);
1073
1080
  return;
1074
1081
  }
1075
1082
  await relay(res, response, meterStreamed);
1076
1083
  } catch (err) {
1077
1084
  if (err instanceof QuoteTooHighError) {
1078
1085
  log(err.message);
1079
- jsonErr(res, 402, err.message, { quote: err.quote });
1086
+ paymentError(res, req, init, 400, 'This request exceeds your spending limit. Try a shorter request or change your limit.');
1080
1087
  } else if (err instanceof UnderfundedError) {
1081
1088
  const msg = await withOnrampLink(err.message, {
1082
1089
  solana: client.address, evm: client.evmAddress, usd: Number(err.accept?.extra?.billedUsd),
1083
1090
  });
1084
1091
  log(msg);
1085
- jsonErr(res, 402, msg);
1092
+ paymentError(res, req, init, 402, `Your available balance does not cover this request yet.\n\n${whopFundBlurb(client.address, client.evmAddress)}`);
1093
+ } else if (err instanceof BalanceUnavailableError) {
1094
+ paymentError(res, req, init, 503, `${err.message}\n\n${whopFundBlurb(client.address, client.evmAddress)}`);
1086
1095
  } else {
1087
1096
  // "fetch failed" alone is undiagnosable — undici hides the real
1088
1097
  // network error in `cause`. Surface it or every transport hiccup looks
@@ -1092,7 +1101,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1092
1101
  const detail = raw;
1093
1102
  log(`proxy error: ${detail}`);
1094
1103
  if (process.env.OPENZOO_DEBUG) console.error(err.stack);
1095
- jsonErr(res, 502, `openzoo proxy error: ${detail}`);
1104
+ paymentError(res, req, init, 502, 'OpenZoo couldn’t connect right now. Please try again.');
1096
1105
  }
1097
1106
  }
1098
1107
  });
@@ -1130,6 +1139,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1130
1139
  }
1131
1140
  }
1132
1141
 
1142
+ // Funding checks run for desktop/background launches as well as terminal sessions.
1143
+ refreshBalances = () => refreshWallet(true);
1144
+ refreshWallet(); refreshCredit(); refreshPrices();
1145
+ const fundingTimer = setInterval(() => { refreshCredit(); refreshWallet(); refreshPrices(); }, 3000);
1146
+ fundingTimer.unref?.();
1147
+ server.on('close', () => clearInterval(fundingTimer));
1148
+
1133
1149
  // AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
1134
1150
  // is applied automatically server-side whenever a balance covers the quote,
1135
1151
  // so buying it once makes every later call skip verify+settle entirely.
@@ -1183,31 +1199,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1183
1199
  } catch { /* RPC hiccup: balance is advisory */ }
1184
1200
  refreshCredit();
1185
1201
  refreshPrices();
1186
- // LIVE REFRESH: poll on an interval (and shortly after each paid call),
1187
- // print ONLY on change, and call out arrivals explicitly.
1188
- refreshBalances = async () => {
1189
- try {
1190
- const snap = await snapshotBalances(client);
1191
- if (!snap.length) return;
1192
- const prev = new Map((lastSnap || []).map((b) => [`${b.chain}:${b.symbol}`, b.ui]));
1193
- const changed = snap.some((b) => Math.abs((prev.get(`${b.chain}:${b.symbol}`) ?? 0) - b.ui) > 1e-9)
1194
- || snap.length !== (lastSnap || []).length;
1195
- if (!changed) return;
1196
- if (lastSnap) {
1197
- for (const b of snap) {
1198
- const gain = b.ui - (prev.get(`${b.chain}:${b.symbol}`) ?? 0);
1199
- if (gain > 1e-9) console.log(`funding arrived: +${gain.toFixed(6)} ${b.symbol}${b.chain !== 'solana' ? ` (${b.chain})` : ''}`);
1200
- }
1201
- }
1202
- lastSnap = snap;
1203
- console.log(`balance: ${balanceLine(snap)}`);
1204
- } catch { /* advisory — never noisy on RPC trouble */ }
1205
- };
1206
- const pollSecs = Number(process.env.OPENZOO_BALANCE_POLL_SECS ?? 45);
1207
- if (pollSecs > 0) {
1208
- const timer = setInterval(refreshBalances, pollSecs * 1000);
1209
- timer.unref?.();
1210
- }
1211
1202
  // Which rails the zoo will actually settle right now, straight off a live
1212
1203
  // 402 — so nobody funds a lane the resource is not currently offering.
1213
1204
  try {
@@ -97,15 +97,12 @@ export function whopFundBlurb(solana, evm) {
97
97
  const url = whopBuyUrl();
98
98
  const base = String(evm || '').trim();
99
99
  return [
100
- `Hey — buy this: ${url}`,
101
- '',
102
- 'Copy-paste THIS Solana address into "what is your Solana address?" so it ties to your account:',
103
- addr,
104
- // OR FUND THE WALLET DIRECTLY. Every failed payment ends here: card via
105
- // Whop, or USDC to either address — those are the only three moves a
106
- // stuck caller has, so they are the whole message.
107
- '',
108
- `Or send USDC directly — Solana: ${addr}${base ? ` · Base: ${base}` : ''}`,
100
+ 'Funding options:',
101
+ `Card: ${url}`,
102
+ `At checkout, use this Solana wallet address to link your purchase: ${addr}`,
103
+ `Solana (USDC): ${addr}`,
104
+ ...(base ? [`Base (USDC): ${base}`] : []),
105
+ 'Your balance refreshes automatically once the payment is confirmed.',
109
106
  ].join('\n');
110
107
  }
111
108
 
@@ -151,11 +148,10 @@ export function settleFailCopy(q402) {
151
148
  const reason = gatewayReason(q402);
152
149
  const code = q402?.advice?.code || q402?.error?.code || '';
153
150
  const fund = isFundInstruction(reason, { code, advice: q402?.advice });
154
- const message = reason
155
- ? `openzoo payment did not settle: ${reason}`
156
- : 'openzoo payment did not settle';
157
- const upstreamish = /upstream|facilitator|internal(?: server)? error|settle(?:ment)? (?:failed|error)/i.test(reason) && !fund;
158
- return { message, status: upstreamish ? 502 : 402, fund, reason, code: String(code || '') };
151
+ const message = fund
152
+ ? 'Your available balance does not cover this request yet. If you just added funds, wait for confirmation and try again.'
153
+ : 'We couldn’t complete the payment for this request. Please try again. If you already added funds, you don’t need to add them again.';
154
+ return { message, status: fund ? 402 : 503, fund, reason, code: String(code || '') };
159
155
  }
160
156
 
161
157
  export async function withOnrampLink(text, dest) {
@@ -164,5 +160,5 @@ export async function withOnrampLink(text, dest) {
164
160
  const blurb = whopFundBlurb(dest?.solana, dest?.evm);
165
161
  if (!blurb) return body;
166
162
  if (/ties to your account/i.test(body) && body.includes(String(dest.solana))) return body;
167
- return `${blurb}\n\n${body}`;
163
+ return `${body}\n\n${blurb}`;
168
164
  }
package/lib/x402.js CHANGED
@@ -422,8 +422,9 @@ export async function tokenBalance(connection, owner, mintStr) {
422
422
  const ata = getAssociatedTokenAddressSync(new PublicKey(mintStr), owner, false, programId);
423
423
  const bal = await connection.getTokenAccountBalance(ata);
424
424
  return { raw: BigInt(bal.value.amount), ui: bal.value.uiAmount };
425
- } catch {
426
- return { raw: 0n, ui: 0 };
425
+ } catch (error) {
426
+ if (/could not find account|account.*(?:does not exist|not found)/i.test(error.message)) return { raw: 0n, ui: 0 };
427
+ throw error;
427
428
  }
428
429
  }
429
430
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.51.7",
4
- "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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.",
3
+ "version": "0.51.8",
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",
7
7
  "bin": {