openzoo 0.49.16 → 0.49.17

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
@@ -1,22 +1,23 @@
1
- import { Connection, PublicKey } from '@solana/web3.js';
2
- import { getAssociatedTokenAddressSync } from '@solana/spl-token';
3
- import { config, fundingLine, evmRpcFor, FUNDING_ASSETS } from './config.js';
1
+ import { Connection } from '@solana/web3.js';
2
+ import { config, fundingLine, evmRpcFor } from './config.js';
4
3
  import { loadOrCreateWallet } from './wallet.js';
5
4
  import {
6
5
  parse402, orderAccepts, railOf, buildPaymentOnline, tokenBalance,
7
- receiptLine, decodeSettleHeader,
6
+ receiptLine, decodeSettleHeader, paymentEnvelope, encodeEnvelope, paymentHeaders,
8
7
  } from './x402.js';
9
8
  import { buildEvmPayment, evmTokenBalance } from './evm.js';
10
- import { acquireWrappedIfNeeded } from './evmwrap.js';
11
9
  import { privateKeyToAccount } from 'viem/accounts';
12
10
  import { withNamespace } from './namespace.js';
13
- import {
14
- resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
15
- rewriteWrapClientError,
16
- } from './wrap.js';
17
- import { applySubscriptionHeaders, loadSubscription, stripAuthorization } from './subscription.js';
18
11
  import { fetchHeaders } from './fetch.js';
19
12
 
13
+ /** Drop any inherited bearer — see the call site: x402 is the only pay lane. */
14
+ function stripAuthorization(headers = {}) {
15
+ const out = { ...headers };
16
+ delete out.authorization;
17
+ delete out.Authorization;
18
+ return out;
19
+ }
20
+
20
21
  export class QuoteTooHighError extends Error {
21
22
  constructor(billedUsd, quote) {
22
23
  super(`openzoo: quote $${billedUsd} exceeds local cap $${config.maxUsdPerCall}. Raise OPENZOO_MAX_USD_PER_CALL to allow.`);
@@ -42,7 +43,8 @@ export class UnderfundedError extends Error {
42
43
  /**
43
44
  * Shared x402-paying HTTP client — the single place payment happens.
44
45
  * The proxy, the demo, and the MCP server all go through PayClient.fetch:
45
- * request → 402 → pick rail (Solana first) → sign → retry with X-PAYMENT.
46
+ * request → 402 → pick rail (Solana first) → sign → retry with the payment
47
+ * headers. x402 is the ONLY pay lane here; there is no bearer that skips it.
46
48
  */
47
49
  /**
48
50
  * Which offered asset last paid, and which are known underfunded — see the
@@ -52,11 +54,11 @@ export class UnderfundedError extends Error {
52
54
  /**
53
55
  * BALANCE CACHE — checked semi-frequently in the background, not per call.
54
56
  *
55
- * MEASURED: a per-request balance probe put a mainnet round trip (and, when the
56
- * row came back short, a full top-up probe: resolvePool + poolState + rent) on
57
- * the critical path of EVERY inference — 4.52s of the 7s a one-line completion
58
- * took. Balances do not move at request rate; a wallet spending $0.00004 a call
59
- * has the same balance it had a minute ago.
57
+ * MEASURED: a per-request balance probe put a mainnet round trip on the critical
58
+ * path of EVERY inference — 4.52s of the 7s a one-line completion took, most of
59
+ * it the since-deleted conversion probe a short row triggered. Balances do not
60
+ * move at request rate; a wallet spending $0.00004 a call has the same balance
61
+ * it had a minute ago.
60
62
  *
61
63
  * Stale-while-revalidate: a cached value is returned IMMEDIATELY and a refresh is
62
64
  * kicked off in the background when it is older than the TTL, so no request ever
@@ -120,53 +122,16 @@ export function resetRailMemory() {
120
122
  lastGoodAsset = null;
121
123
  }
122
124
 
123
- /**
124
- * Skip wrap / pool / sendWrap when there is nothing to convert.
125
- * wrapped < need AND underlying == 0 → UnderfundedError, no RPC walk.
126
- * A funded wallet (underlying > 0) still wraps.
127
- */
128
- export function skipWrapWhenEmpty(wrappedRaw, underlyingRaw, need) {
129
- const wrapped = wrappedRaw == null ? 0n : BigInt(wrappedRaw);
130
- const underlying = underlyingRaw == null ? 0n : BigInt(underlyingRaw);
131
- const want = need == null ? 0n : BigInt(need);
132
- return wrapped < want && underlying <= 0n;
133
- }
134
-
135
- /**
136
- * Cheap underlying mint for a 402 row — directory acquire address, or the
137
- * known USDC / TOKEN / LEOS twin named in extra.symbol. No chain probe.
138
- */
139
- export function hintedUnderlyingMint(accept) {
140
- const acq = accept?.extra?.acquire?.underlying;
141
- const addr = typeof acq === 'string' ? acq : acq?.address;
142
- if (addr && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr)) return addr;
143
- const plain = String(accept?.extra?.symbol || '').replace(/^[wy]/i, '').replace(/x$/i, '').toUpperCase();
144
- return FUNDING_ASSETS.find((a) => a.symbol === plain)?.mint || null;
145
- }
146
-
147
- /** True when every Solana funding mint (USDC / TOKEN / LEOS) reads 0. */
148
- export function solanaFundingEmpty(balances) {
149
- const rows = Array.isArray(balances) ? balances : [];
150
- if (!rows.length) return false;
151
- return rows.every((b) => (b?.raw == null ? 0n : BigInt(b.raw)) <= 0n);
152
- }
153
125
 
154
126
  export class PayClient {
155
127
  /**
156
- * @param {{keypair?: import('@solana/web3.js').Keypair, evmPrivateKey?: string}} [burner]
157
- * Spend from a SPECIFIC wallet instead of this machine's ~/.openzoo/wallet.json.
158
- * @openzoobot needs this: the asker pays from their own derived burner, so
159
- * one poller process settles from a different wallet per question. Without
160
- * the override every X user's question would be paid by the operator's
161
- * machine wallet — which is not "the asker pays", it is us paying for
162
- * everyone and calling it x402.
128
+ * Always this machine's ~/.openzoo/wallet.json. The constructor used to take
129
+ * a per-user derived burner so the X bot could make each asker pay their own
130
+ * way; that bot is gone and nothing else ever passed one, so the override was
131
+ * a second wallet path with no caller and no test.
163
132
  */
164
- constructor(burner) {
165
- // A derived burner is only ever used to make SOMEONE ELSE pay, so it
166
- // implies x402-only. Defaulting this off would silently reintroduce the
167
- // bug it exists to prevent.
168
- this.noSubscription = Boolean(burner?.keypair) && burner?.allowSubscription !== true;
169
- const w = burner?.keypair ? { ...burner, created: false, path: '(derived)' } : loadOrCreateWallet();
133
+ constructor() {
134
+ const w = loadOrCreateWallet();
170
135
  this.keypair = w.keypair;
171
136
  this.evmPrivateKey = w.evmPrivateKey;
172
137
  this.walletCreated = w.created;
@@ -198,71 +163,30 @@ export class PayClient {
198
163
  }
199
164
 
200
165
  /**
201
- * What this wallet can actually PAY with on this rail, in settlement-asset
202
- * units — wrapped holdings PLUS the plain token it can convert on the fly.
203
- *
204
- * balanceForAccept answers a different question: how much of the WRAPPED
205
- * asset is sitting there right now. Every quote names the wrapper (wTOKENx,
206
- * the RH vault twins), so a wallet holding 845,486 plain TOKEN read as
207
- * "$3.14 covered" — only the sliver already wrapped — while `openzoo balance`
208
- * showed the full holding priced as `$?`. Two different wrong answers about
209
- * the same wallet, and a user was told to fund an account with $177 in it.
166
+ * What this wallet can PAY with on this rail, in whole-dollar units.
210
167
  *
211
- * buildPaymentFor already wraps what it needs at payment time (topUpQuotedAsset
212
- * on Solana, acquireWrappedIfNeeded on EVM), so the convertible balance IS
213
- * spendable and the estimate was simply lying by omission. Advisory only:
214
- * anything unreadable degrades to the wrapped figure rather than throwing,
215
- * because this feeds a display and a top-up hint, never a settlement.
168
+ * This used to add a second term: the plain token behind the quoted WRAPPER,
169
+ * because every row named a twin (yUSDCx / wTOKENx / the RH vault) and a
170
+ * wallet holding 845,486 plain TOKEN read as "$3.14 covered" — the sliver
171
+ * already wrapped. The gateway now quotes the raw native mint on every rail,
172
+ * so the balance the row asks about is the balance the wallet holds and the
173
+ * second term would double-count it. Advisory only: it feeds a display and a
174
+ * funding hint, never a settlement.
216
175
  */
217
176
  async spendableUsdForAccept(accept) {
218
177
  const perUsd = BigInt(accept.maxAmountRequired || '0');
219
178
  if (perUsd <= 0n) return 0;
220
- const wrappedRaw = await this.balanceForAccept(accept).catch(() => 0n);
221
- let usd = Number(wrappedRaw * 1000n / perUsd) / 1000;
222
-
223
- // Price the PLAIN token with the quote's own tokenUsd, NOT with the pool's
224
- // supply/reserves ratio. That ratio is share accounting, not a price: the
225
- // live wTOKENx pool reads supply/reserves ≈ 5659, so treating it as a
226
- // conversion rate valued 845,486 TOKEN at $1,004,132. The 402 already
227
- // publishes what a whole token is worth, and the wrapper is a claim on the
228
- // same asset — so underlying and wrapped are priced identically, which is
229
- // exactly what `openzoo balance` now shows.
230
- const px = Number(accept?.extra?.tokenUsd);
231
- if (!Number.isFinite(px) || px <= 0) return usd;
232
- const rail = railOf(accept);
233
- try {
234
- if (rail === 'solana') {
235
- const pool = await resolvePool(this.connection, accept.asset);
236
- if (!pool) return usd;
237
- const under = await tokenBalance(this.connection, this.keypair.publicKey,
238
- pool.underlying.toBase58());
239
- usd += Number(under?.ui || 0) * px;
240
- return usd;
241
- }
242
- // EVM wrappers are ERC-4626; asset() names the plain token behind them.
243
- const { createPublicClient, http } = await import('viem');
244
- const pc = createPublicClient({ transport: http(evmRpcFor(rail)) });
245
- const ABI = [
246
- { name: 'asset', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
247
- { name: 'decimals', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
248
- ];
249
- const under = await pc.readContract({ address: accept.asset, abi: ABI, functionName: 'asset', args: [] });
250
- const raw = await evmTokenBalance({ rpcUrl: evmRpcFor(rail), token: under, owner: this.evmAddress });
251
- if (!raw || raw <= 0n) return usd;
252
- const dec = Number(accept?.extra?.decimals ?? 18);
253
- usd += (Number(raw) / 10 ** dec) * px;
254
- return usd;
255
- } catch {
256
- return usd; // not a wrapper, or a rail we cannot read
257
- }
258
- }
259
-
260
- async solanaFundingBalances() {
261
- const owner = this.keypair.publicKey;
262
- return Promise.all(FUNDING_ASSETS.map((a) => cachedTokenBalance(this.connection, owner, a.mint)));
179
+ const raw = await this.balanceForAccept(accept).catch(() => 0n);
180
+ return Number(raw * 1000n / perUsd) / 1000;
263
181
  }
264
182
 
265
- async buildPaymentFor(accept, onStage) {
183
+ /**
184
+ * `challenge` is the whole 402 body, not decoration: the envelope echoes its
185
+ * `resource` block and its `x402` summary decides which network string the
186
+ * verifier will accept. Both rails build their payload here and the envelope
187
+ * is assembled once, so neither can drift into the v1 shape on its own.
188
+ */
189
+ async buildPaymentFor(accept, onStage, challenge) {
266
190
  const rail = railOf(accept);
267
191
  if (rail === 'solana') {
268
192
  const need = BigInt(accept.maxAmountRequired);
@@ -272,31 +196,13 @@ export class PayClient {
272
196
  if (bal.raw < need && bal.cached) {
273
197
  bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true });
274
198
  }
275
- if (bal.raw < need) {
276
- // Empty first-run burner: 0 wrapped + 0 USDC/TOKEN/LEOS. Do not
277
- // resolvePool / poolState / sendWrap just to learn that.
278
- const funding = await this.solanaFundingBalances();
279
- if (solanaFundingEmpty(funding)) {
280
- throw new UnderfundedError(accept, 0, this.address);
281
- }
282
- const underMint = hintedUnderlyingMint(accept);
283
- if (underMint) {
284
- let under = await cachedTokenBalance(this.connection, this.keypair.publicKey, underMint);
285
- if (under.raw <= 0n && under.cached) {
286
- under = await cachedTokenBalance(this.connection, this.keypair.publicKey, underMint, { force: true });
287
- }
288
- if (skipWrapWhenEmpty(bal.raw, under.raw, need)) {
289
- throw new UnderfundedError(accept, under.ui, this.address);
290
- }
291
- }
292
- const topUp = await this.topUpQuotedAsset(accept, need, onStage);
293
- if (topUp.preInstructions) {
294
- return buildPaymentOnline(this.connection, this.keypair, accept, {
295
- preInstructions: topUp.preInstructions,
296
- });
297
- }
298
- }
299
- return buildPaymentOnline(this.connection, this.keypair, accept);
199
+ // SHORT IS SHORT. The 402 quotes the raw native mint, so there is nothing
200
+ // to convert and no pool to walk: the wallet either holds the asset or it
201
+ // does not. This branch used to run resolvePool + poolState + a wrap
202
+ // (~4.5s) to mint a Token-2022 twin the gateway no longer accepts.
203
+ if (bal.raw < need) throw new UnderfundedError(accept, bal.ui, this.address);
204
+ const built = await buildPaymentOnline(this.connection, this.keypair, accept);
205
+ return { ...built, header: encodeEnvelope(paymentEnvelope(challenge, accept, built.payload)) };
300
206
  }
301
207
  // OPENZOO_RAIL=robinhood is explicit intent — it opens the RH gate the
302
208
  // same way OPENZOO_ENABLE_RH=1 does.
@@ -309,111 +215,22 @@ export class PayClient {
309
215
  if (owner) {
310
216
  const bal = await evmTokenBalance({ rpcUrl, token: accept.asset, owner }).catch(() => null);
311
217
  if (bal !== null && bal < BigInt(accept.maxAmountRequired)) {
312
- if (rail === 'robinhood') {
313
- // The RH settlement asset is a vault twin over the plain token the
314
- // user actually holds. Convert exactly enough at payment time
315
- // (approve + deposit) — the EVM mirror of the Solana top-up above.
316
- // Throws NeedsGasError / UnderlyingShortError with exact, plain-
317
- // token-named instructions when the wallet cannot convert. An RPC
318
- // hiccup mid-probe falls through to the flat underfunded message.
319
- let converted = null;
320
- try {
321
- converted = await acquireWrappedIfNeeded({
322
- rpcUrl, accept, evmPrivateKey: this.evmPrivateKey, onStage,
323
- });
324
- } catch (e) {
325
- if (e?.name === 'NeedsGasError' || e?.name === 'UnderlyingShortError' || /openzoo/.test(e?.message || '')) throw e;
326
- converted = null;
327
- }
328
- if (!converted || converted.wrapper === false || (!converted.acquired && converted.wrapper !== null)) {
329
- const line = `The wallet must hold the token this row is quoted in — fund ${owner} on Robinhood Chain, or see https://x402.accrue.fund/start.`;
330
- throw new UnderfundedError(accept, null, owner, { line });
331
- }
332
- } else {
333
- throw new UnderfundedError(accept, null, owner, { line: `Send a few cents of USDC on Base to ${owner}.` });
334
- }
218
+ // Robinhood used to quote an ERC-4626 vault twin here and the shim
219
+ // ran approve+deposit to mint it. The gateway now quotes canonical
220
+ // Paxos USDG, which implements EIP-3009 directly, so the only
221
+ // question left on every EVM rail is whether the wallet holds enough.
222
+ const sym = accept?.extra?.symbol || 'the quoted asset';
223
+ const where = rail === 'robinhood' ? 'Robinhood Chain' : rail === 'base' ? 'Base' : accept.network;
224
+ throw new UnderfundedError(accept, null, owner, {
225
+ line: `Send a few cents of ${sym} on ${where} to ${owner}.`,
226
+ });
335
227
  }
336
228
  }
337
- return buildEvmPayment({ accept, evmPrivateKey: this.evmPrivateKey });
229
+ return buildEvmPayment({ accept, evmPrivateKey: this.evmPrivateKey, challenge });
338
230
  }
339
231
  throw new Error(`no payment builder for rail ${rail} (network ${accept.network})`);
340
232
  }
341
233
 
342
- /**
343
- * Internal: make sure the wallet holds enough of the exact asset the 402
344
- * quotes, by converting the user's plain USDC (or whatever underlying the
345
- * pool takes) at payment time. Users never see or hold the quoted asset.
346
- *
347
- * Returns {} when the balance now covers the quote, or
348
- * { preInstructions } when the wallet cannot pay network fees itself — the
349
- * conversion then rides inside the gateway-sponsored payment transaction
350
- * (the 402's feePayer covers fees and any account rent).
351
- * Throws UnderfundedError when the underlying balance cannot cover it.
352
- */
353
- async topUpQuotedAsset(accept, need, onStage) {
354
- const owner = this.keypair.publicKey;
355
- const pool = await resolvePool(this.connection, accept.asset).catch(() => null);
356
- if (!pool) throw new UnderfundedError(accept, null, this.address);
357
-
358
- // Balances first. 0 wrapped + 0 underlying → throw. Never poolState or
359
- // sendWrap just to discover an empty burner (measured ~4.5s per row).
360
- const [wrappedNow, underlyingNow] = await Promise.all([
361
- tokenBalance(this.connection, owner, accept.asset),
362
- tokenBalance(this.connection, owner, pool.underlying.toBase58()),
363
- ]);
364
- if (skipWrapWhenEmpty(wrappedNow.raw, underlyingNow.raw, need)) {
365
- throw new UnderfundedError(accept, underlyingNow.ui, this.address);
366
- }
367
-
368
- for (let attempt = 0; attempt < 3; attempt++) {
369
- const bal = attempt === 0 ? wrappedNow : await tokenBalance(this.connection, owner, accept.asset);
370
- const short = need - bal.raw;
371
- if (short <= 0n) return {};
372
-
373
- const { reserves, supply } = await poolState(this.connection, pool);
374
- const minDeposit = depositForShares(short, reserves, supply);
375
- const underlyingBal = attempt === 0
376
- ? underlyingNow
377
- : await tokenBalance(this.connection, owner, pool.underlying.toBase58());
378
- if (underlyingBal.raw < minDeposit) {
379
- throw new UnderfundedError(accept, underlyingBal.ui, this.address);
380
- }
381
- // WRAP EVERYTHING, NOT THE SHORTFALL. Wrapping per-payment meant every
382
- // paid call re-ran resolvePool+poolState+wrap+confirm (~4.5s) and
383
- // re-opened the same balance race that false-paywalled a funded wallet.
384
- // Wrapped twins exist only to be spent on payments, so there is nothing
385
- // to preserve by leaving the underlying raw: convert the full balance
386
- // once, and every later payment takes the fast path (measured 0.36s)
387
- // straight from wrapped funds. (Operator directive, verbatim: the burner
388
- // must "autotopup with all my crypto".) The bundled no-SOL path below
389
- // keeps the minimal amount — it rides inside the payment tx, where a
390
- // full-balance wrap would bloat a transaction we do not control.
391
- const deposit = underlyingBal.raw > minDeposit ? underlyingBal.raw : minDeposit;
392
-
393
- onStage?.('funding');
394
- const wrappedAta = getAssociatedTokenAddressSync(pool.wrapped, owner, false, pool.wrappedProgram);
395
- const [lamports, ataInfo] = await Promise.all([
396
- this.connection.getBalance(owner),
397
- this.connection.getAccountInfo(wrappedAta),
398
- ]);
399
- const lamportsNeeded = 10000 + (ataInfo ? 0 : 2400000); // tx fee + possible ATA rent
400
- if (lamports < lamportsNeeded && accept.extra?.feePayer) {
401
- // No SOL for a standalone conversion — bundle it into the payment tx.
402
- const rentPayer = new PublicKey(accept.extra.feePayer);
403
- return {
404
- preInstructions: buildWrapInstructions({ pool, owner, depositRaw: minDeposit, rentPayer }),
405
- };
406
- }
407
- const sig = await sendWrap(this.connection, this.keypair, pool, deposit);
408
- this.lastTopUpSig = sig; // internal breadcrumb, never printed to users
409
- if (process.env.OPENZOO_DEBUG) console.error(`[openzoo debug] top-up tx ${sig}`);
410
- }
411
-
412
- const finalBal = await tokenBalance(this.connection, owner, accept.asset);
413
- if (finalBal.raw < need) throw new UnderfundedError(accept, null, this.address);
414
- return {};
415
- }
416
-
417
234
  /**
418
235
  * fetch that transparently pays a 402 once.
419
236
  * Returns { response, paid, accept?, settle?, receipt? }.
@@ -424,21 +241,14 @@ export class PayClient {
424
241
  // Contexts are tenanted by this namespace server-side — a request without
425
242
  // it cannot see corpora this wallet bound.
426
243
  init = { ...init, headers: withNamespace(init.headers || {}, { keypair: this.keypair }) };
427
- // Subscription key · no x402. A stored Stripe key is a bearer on the zoo
428
- // API (same as the public /billing/done snippet). Wallet/x402 stays if
429
- // there is no key, or if the gateway still answers 402.
430
- // `noSubscription` forces the WALLET to pay. @openzoobot needs it: the
431
- // operator's stored subscription key is on the same machine as the poller,
432
- // so without this every asker's paid question would be settled on OUR key
433
- // while the reply claimed x402 — and an empty burner would answer happily,
434
- // which is precisely how this was caught.
435
- const sub = this.noSubscription ? null : loadSubscription();
436
- if (sub?.key) init = { ...init, headers: applySubscriptionHeaders(init.headers, sub) };
437
- // Strip any bearer the caller inherited, for the same reason.
438
- if (this.noSubscription) init = { ...init, headers: stripAuthorization(init.headers || {}) };
244
+ // THE WALLET IS THE ONLY PAY LANE. A stored bearer used to short-circuit
245
+ // everything below it, which meant an inherited Authorization header could
246
+ // buy inference this wallet never paid for. The gateway is pay-per-request
247
+ // only, so any bearer the caller carried in is dropped here.
248
+ init = { ...init, headers: stripAuthorization(init.headers || {}) };
439
249
  const first = await fetchHeaders(url, init);
440
250
  if (first.status !== 402) {
441
- return { response: first, paid: false, subscription: Boolean(sub?.key && first.ok) };
251
+ return { response: first, paid: false };
442
252
  }
443
253
 
444
254
  const quote = parse402(await first.json());
@@ -448,16 +258,12 @@ export class PayClient {
448
258
  // affordable does the call fail — never because the first-choice asset
449
259
  // alone ran dry while another funded one sat in the wallet.
450
260
  const ordered = orderAccepts(quote, config.token, { allowRH: this.allowRH, forceRail: config.rail });
451
- // RAIL MEMORY — the single biggest source of per-call latency.
452
- // MEASURED: the first offered row (yUSDCx) was underfunded, and finding that
453
- // out costs 4.52s, because an underfunded Solana row attempts a top-up first
454
- // (resolvePool + poolState + balances + rent-exemption lookups). The row that
455
- // actually pays (wTOKENx) then builds in 0.36s. That 4.5s was being re-paid on
456
- // EVERY request to rediscover a fact that had not changed.
457
- // So: remember which asset just paid and try it first, and skip assets known
458
- // underfunded within the TTL. Bounded and self-healing — the memo expires, a
459
- // funded wallet is re-tried automatically, and if every row is memoized we fall
460
- // back to the full list rather than manufacturing a dead end.
261
+ // RAIL MEMORY. Remember which asset just paid and try it first, and skip
262
+ // assets known underfunded within the TTL, so a wallet with three offered
263
+ // rows does not re-probe the two it cannot pay on every single call.
264
+ // Bounded and self-healing — the memo expires, a funded wallet is re-tried
265
+ // automatically, and if every row is memoized we fall back to the full list
266
+ // rather than manufacturing a dead end.
461
267
  const candidates = orderCandidatesByMemory(ordered);
462
268
  onStage?.('quoted');
463
269
  let accept = null;
@@ -471,7 +277,7 @@ export class PayClient {
471
277
  continue;
472
278
  }
473
279
  try {
474
- payment = await this.buildPaymentFor(cand, onStage);
280
+ payment = await this.buildPaymentFor(cand, onStage, quote);
475
281
  accept = cand;
476
282
  lastGoodAsset = memoKey(cand);
477
283
  underfundedUntil.delete(memoKey(cand));
@@ -480,9 +286,7 @@ export class PayClient {
480
286
  }
481
287
  break;
482
288
  } catch (e) {
483
- const funding = e instanceof UnderfundedError
484
- || e?.name === 'UnderlyingShortError' || e?.name === 'NeedsGasError';
485
- if (!funding) throw e;
289
+ if (!(e instanceof UnderfundedError)) throw e;
486
290
  // Remember it so the next call does not re-pay the discovery cost.
487
291
  underfundedUntil.set(memoKey(cand), Date.now() + RAIL_MEMO_MS);
488
292
  fundErrs.push({ sym: cand?.extra?.symbol || cand?.asset, err: e });
@@ -499,7 +303,7 @@ export class PayClient {
499
303
  onStage?.('paying');
500
304
  const response = await fetchHeaders(url, {
501
305
  ...init,
502
- headers: { ...stripAuthorization(init.headers || {}), 'X-PAYMENT': payment.header },
306
+ headers: { ...stripAuthorization(init.headers || {}), ...paymentHeaders(payment.header) },
503
307
  });
504
308
  const settle = decodeSettleHeader(response.headers.get('x-payment-response'))
505
309
  || { signature: payment.ownerSignature };
@@ -533,7 +337,7 @@ export class PayClient {
533
337
  body: JSON.stringify(bodyObj),
534
338
  }, { onStage });
535
339
  if (!response.ok) {
536
- const text = rewriteWrapClientError((await response.text()).slice(0, 500));
340
+ const text = (await response.text()).slice(0, 500);
537
341
  throw new Error(`zoo returned HTTP ${response.status}: ${text}`);
538
342
  }
539
343
  return { data: await response.json(), receipt };
package/lib/podagent.mjs CHANGED
@@ -257,7 +257,7 @@ async function httpErrorNote(status) {
257
257
  if (w.funded === false) {
258
258
  return `(payment required — HTTP 402, the wallet is empty. ${w.funding}. EVM (Base/Robinhood): ${w.evm}.)`;
259
259
  }
260
- return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries, though the wallet holds ${w.balances || 'a balance'}. Send it again; if it keeps failing the quoted asset may not be convertible right now. Fund with: ${w.funding})`;
260
+ return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries, though the wallet holds ${w.balances || 'a balance'}. Send it again; if it keeps failing, the wallet may hold a different asset than the one this 402 quotes. Fund with: ${w.funding})`;
261
261
  }
262
262
  } catch { /* proxy unreachable — fall through to the generic note */ }
263
263
  return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries. Run \`npx openzoo\` to check wallet balances.)`;
@@ -267,24 +267,13 @@ async function httpErrorNote(status) {
267
267
  return status ? `(request failed — HTTP ${status})` : '';
268
268
  }
269
269
 
270
- /** Do not dump raw Solana wrap-sim logs into the chat bubble. */
271
- function sanitizeProxiedError(msg) {
272
- if (!msg) return msg;
273
- const s = String(msg);
274
- if (/0x6a\b|custom program error:\s*106\b|NotEnoughAccounts/i.test(s)) {
275
- return 'wrap ix has too few accounts (need 9); old 5-account wrap is dead';
276
- }
277
- if (/0x70\b|custom program error:\s*112\b|TokenProgramMismatch/i.test(s)) {
278
- return 'unwrap ix is missing the unwrapped token program (account 8); 8-account unwrap is dead';
279
- }
280
- return s;
281
- }
282
270
 
283
271
  // A 402 that reached this layer means the proxy's own x402 retry gave up on
284
272
  // this attempt, but the NEXT attempt usually settles (measured: same wallet,
285
273
  // same rail, second call pays fine). Surfacing that as a chat message makes
286
274
  // the user do the retry by hand — so do it here instead.
287
- // Empty/underfunded is NOT that handshake: retrying just re-walks wrap.
275
+ // Empty/underfunded is NOT that handshake: the balance will not have changed,
276
+ // so retrying just re-reads it and fails the same way.
288
277
  const PAYMENT_RETRIES = 3;
289
278
 
290
279
  export function isUnderfunded402Body(body) {
@@ -443,7 +432,7 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
443
432
  const msg = j?.choices?.[0]?.message;
444
433
  const content = msg?.content;
445
434
  const thinking = messageReasoning(msg);
446
- const proxied = sanitizeProxiedError(j?.error?.message);
435
+ const proxied = j?.error?.message;
447
436
  const text = content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status)));
448
437
  if (thinking) onDelta(thinking, { think: true });
449
438
  if (text) onDelta(text);
@@ -815,7 +804,7 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
815
804
  const j = await r.json().catch(() => ({}));
816
805
  const msg = j?.choices?.[0]?.message;
817
806
  const content = msg?.content;
818
- const proxied = sanitizeProxiedError(j?.error?.message);
807
+ const proxied = j?.error?.message;
819
808
  const text = wrapThink(messageReasoning(msg), content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status))));
820
809
  lastFail = { model: 'gateway', text: text || '', error: r.ok ? undefined : `HTTP ${r.status}` };
821
810
  if (isRaceCountable(lastFail)) {
package/lib/proxy.js CHANGED
@@ -33,10 +33,8 @@ import { responsesToChat, chatToResponses, writeResponsesSse } from './responses
33
33
  import { streamOpenAIToResponses } from './responses-stream.js';
34
34
  import { loadSessionSpend, saveSessionSpend } from './session.js';
35
35
  import { creditBalance, quotedPrices } from './info.js';
36
- import { subscriptionPublicView } from './subscription.js';
37
36
  import { priceHoldings } from './livestatus.js';
38
37
  import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettle.js';
39
- import { rewriteWrapClientError } from './wrap.js';
40
38
  import { fetchHeaders } from './fetch.js';
41
39
 
42
40
  const HOP_BY_HOP = new Set([
@@ -975,7 +973,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
975
973
  version,
976
974
  spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
977
975
  creditUsd, chainUsd: money.chainUsd, lastQuoteUsd,
978
- subscription: subscriptionPublicView(),
979
976
  }));
980
977
  return;
981
978
  }
@@ -999,7 +996,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
999
996
  creditUsd,
1000
997
  chainUsd: money.chainUsd,
1001
998
  holdings: money.holdings,
1002
- subscription: subscriptionPublicView(),
1003
999
  }));
1004
1000
  return;
1005
1001
  }
@@ -1107,9 +1103,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1107
1103
  },
1108
1104
  mcp: `${self.replace(/\/v1$/, '')}/mcp`,
1109
1105
  upstream: config.apiBase,
1110
- payment: subscriptionPublicView().active
1111
- ? 'subscription key � no x402 � wallet/x402 remains the other method'
1112
- : 'x402 per request from the operator\'s local burner wallet � no API key, no account',
1106
+ payment: 'x402 per request from the operator\'s local burner wallet - no API key, no account',
1113
1107
  auth: viaTunnel
1114
1108
  ? 'this public URL requires the oz_… bearer for paid endpoints; /v1/models and /v1/hrr/bind are free'
1115
1109
  : 'localhost is keyless',
@@ -1561,8 +1555,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1561
1555
  // rewrite never gets its chance.
1562
1556
  const path = (req.url || '').split('?')[0];
1563
1557
  if (req.method === 'GET' && path === '/v1/models') {
1564
- // Catalog is chrome, not a paid call. Paying the list would wrap-walk
1565
- // an empty burner (~4.5s/row) and stall first paint / harness probe.
1558
+ // Catalog is chrome, not a paid call. Paying the list would put a 402
1559
+ // round trip and a balance read in front of first paint.
1566
1560
  try {
1567
1561
  const response = await fetchHeaders(url, init);
1568
1562
  if (response.ok) {
@@ -1940,7 +1934,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1940
1934
  // transport hiccup looks identical to a payment bug.
1941
1935
  const cause = err.cause?.message || err.cause?.code || err.cause;
1942
1936
  const raw = cause ? `${err.message} (${cause})` : err.message;
1943
- const detail = rewriteWrapClientError(raw);
1937
+ const detail = raw;
1944
1938
  log(`proxy error: ${detail}`);
1945
1939
  if (process.env.OPENZOO_DEBUG) console.error(err.stack);
1946
1940
  jsonErr(res, 502, `openzoo proxy error: ${detail}`);