openzoo 0.20.6 → 0.20.7

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.
Files changed (2) hide show
  1. package/lib/pay.js +101 -2
  2. package/package.json +1 -1
package/lib/pay.js CHANGED
@@ -41,6 +41,82 @@ export class UnderfundedError extends Error {
41
41
  * The proxy, the demo, and the MCP server all go through PayClient.fetch:
42
42
  * request → 402 → pick rail (Solana first) → sign → retry with X-PAYMENT.
43
43
  */
44
+ /**
45
+ * Which offered asset last paid, and which are known underfunded — see the
46
+ * comment at the call site in fetch(). Process-lifetime with a TTL so a wallet
47
+ * that gets funded mid-session recovers on its own.
48
+ */
49
+ /**
50
+ * BALANCE CACHE — checked semi-frequently in the background, not per call.
51
+ *
52
+ * MEASURED: a per-request balance probe put a mainnet round trip (and, when the
53
+ * row came back short, a full top-up probe: resolvePool + poolState + rent) on
54
+ * the critical path of EVERY inference — 4.52s of the 7s a one-line completion
55
+ * took. Balances do not move at request rate; a wallet spending $0.00004 a call
56
+ * has the same balance it had a minute ago.
57
+ *
58
+ * Stale-while-revalidate: a cached value is returned IMMEDIATELY and a refresh is
59
+ * kicked off in the background when it is older than the TTL, so no request ever
60
+ * waits on the network for it. Only a cold cache (first call of the process)
61
+ * blocks. A payment decrements the cached figure locally so a burst of calls
62
+ * cannot overdraw between refreshes.
63
+ */
64
+ const BALANCE_TTL_MS = Number(process.env.OPENZOO_BALANCE_TTL_MS || 60_000);
65
+ const balanceCache = new Map(); // key -> { raw, ui, at, refreshing }
66
+
67
+ const balKey = (owner, mint) => `${owner}|${mint}`;
68
+
69
+ async function cachedTokenBalance(connection, owner, mint, { force = false } = {}) {
70
+ const key = balKey(owner.toBase58 ? owner.toBase58() : String(owner), mint);
71
+ const hit = balanceCache.get(key);
72
+ const fresh = hit && !force && (Date.now() - hit.at) < BALANCE_TTL_MS;
73
+ if (hit && !force) {
74
+ if (!fresh && !hit.refreshing) {
75
+ // SEMI-FREQUENT REFRESH: fire and forget, so the caller is never blocked.
76
+ hit.refreshing = true;
77
+ tokenBalance(connection, owner, mint)
78
+ .then((b) => balanceCache.set(key, { raw: b.raw, ui: b.ui, at: Date.now(), refreshing: false }))
79
+ .catch(() => { hit.refreshing = false; }); // keep the last good value
80
+ }
81
+ return { raw: hit.raw, ui: hit.ui, cached: true, ageMs: Date.now() - hit.at };
82
+ }
83
+ const b = await tokenBalance(connection, owner, mint);
84
+ balanceCache.set(key, { raw: b.raw, ui: b.ui, at: Date.now(), refreshing: false });
85
+ return { ...b, cached: false, ageMs: 0 };
86
+ }
87
+
88
+ /** Debit what we just spent so a burst cannot overdraw a cached figure. */
89
+ function debitCachedBalance(owner, mint, amount) {
90
+ const key = balKey(owner.toBase58 ? owner.toBase58() : String(owner), mint);
91
+ const hit = balanceCache.get(key);
92
+ if (hit) hit.raw = hit.raw > amount ? hit.raw - amount : 0n;
93
+ }
94
+
95
+ /** Test seam. */
96
+ export function resetBalanceCache() { balanceCache.clear(); }
97
+
98
+ const RAIL_MEMO_MS = Number(process.env.OPENZOO_RAIL_MEMO_MS || 120_000);
99
+ const underfundedUntil = new Map(); // asset -> epoch ms after which to re-try it
100
+ let lastGoodAsset = null;
101
+
102
+ const memoKey = (a) => `${a?.network || ''}|${a?.asset || ''}`;
103
+
104
+ /** lastGood first, known-underfunded last (never dropped — only deprioritised). */
105
+ export function orderCandidatesByMemory(cands, now = Date.now()) {
106
+ const skip = (c) => (underfundedUntil.get(memoKey(c)) ?? 0) > now;
107
+ const good = (c) => lastGoodAsset && memoKey(c) === lastGoodAsset;
108
+ // Stable partition: proven payer, then untried, then recently-underfunded. The
109
+ // last group is KEPT so a wallet funded seconds ago is still reachable in the
110
+ // same call if nothing else works.
111
+ return [...cands].sort((a, b) => (good(b) - good(a)) || (skip(a) - skip(b)));
112
+ }
113
+
114
+ /** Test seam: forget everything the rail memory learned. */
115
+ export function resetRailMemory() {
116
+ underfundedUntil.clear();
117
+ lastGoodAsset = null;
118
+ }
119
+
44
120
  export class PayClient {
45
121
  constructor() {
46
122
  const w = loadOrCreateWallet();
@@ -65,7 +141,12 @@ export class PayClient {
65
141
  const rail = railOf(accept);
66
142
  if (rail === 'solana') {
67
143
  const need = BigInt(accept.maxAmountRequired);
68
- const bal = await tokenBalance(this.connection, this.keypair.publicKey, accept.asset);
144
+ let bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset);
145
+ // Never declare a wallet short on a STALE read — the expensive top-up path
146
+ // and the underfunded error both deserve a live number.
147
+ if (bal.raw < need && bal.cached) {
148
+ bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true });
149
+ }
69
150
  if (bal.raw < need) {
70
151
  const topUp = await this.topUpQuotedAsset(accept, need, onStage);
71
152
  if (topUp.preInstructions) {
@@ -188,7 +269,18 @@ export class PayClient {
188
269
  // it HOLDS: every offered row is tried best-first, and only when NONE is
189
270
  // affordable does the call fail — never because the first-choice asset
190
271
  // alone ran dry while another funded one sat in the wallet.
191
- const candidates = orderAccepts(quote, config.token, { allowRH: this.allowRH, forceRail: config.rail });
272
+ const ordered = orderAccepts(quote, config.token, { allowRH: this.allowRH, forceRail: config.rail });
273
+ // RAIL MEMORY — the single biggest source of per-call latency.
274
+ // MEASURED: the first offered row (yUSDCx) was underfunded, and finding that
275
+ // out costs 4.52s, because an underfunded Solana row attempts a top-up first
276
+ // (resolvePool + poolState + balances + rent-exemption lookups). The row that
277
+ // actually pays (wTOKENx) then builds in 0.36s. That 4.5s was being re-paid on
278
+ // EVERY request to rediscover a fact that had not changed.
279
+ // So: remember which asset just paid and try it first, and skip assets known
280
+ // underfunded within the TTL. Bounded and self-healing — the memo expires, a
281
+ // funded wallet is re-tried automatically, and if every row is memoized we fall
282
+ // back to the full list rather than manufacturing a dead end.
283
+ const candidates = orderCandidatesByMemory(ordered);
192
284
  onStage?.('quoted');
193
285
  let accept = null;
194
286
  let payment = null;
@@ -203,11 +295,18 @@ export class PayClient {
203
295
  try {
204
296
  payment = await this.buildPaymentFor(cand, onStage);
205
297
  accept = cand;
298
+ lastGoodAsset = memoKey(cand);
299
+ underfundedUntil.delete(memoKey(cand));
300
+ if (railOf(cand) === 'solana') {
301
+ try { debitCachedBalance(this.keypair.publicKey, cand.asset, BigInt(cand.maxAmountRequired)); } catch { /* advisory */ }
302
+ }
206
303
  break;
207
304
  } catch (e) {
208
305
  const funding = e instanceof UnderfundedError
209
306
  || e?.name === 'UnderlyingShortError' || e?.name === 'NeedsGasError';
210
307
  if (!funding) throw e;
308
+ // Remember it so the next call does not re-pay the discovery cost.
309
+ underfundedUntil.set(memoKey(cand), Date.now() + RAIL_MEMO_MS);
211
310
  fundErrs.push({ sym: cand?.extra?.symbol || cand?.asset, err: e });
212
311
  }
213
312
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.20.6",
3
+ "version": "0.20.7",
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",