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/README.md +8 -8
- package/bin/openzoo.js +0 -11
- package/lib/config.js +18 -28
- package/lib/demo.js +7 -10
- package/lib/evm.js +22 -19
- package/lib/grokui.mjs +13 -317
- package/lib/info.js +9 -10
- package/lib/mcp.js +1 -3
- package/lib/pay.js +74 -270
- package/lib/podagent.mjs +5 -16
- package/lib/proxy.js +4 -10
- package/lib/x402.js +145 -54
- package/package.json +2 -2
- package/lib/evmwrap.js +0 -163
- package/lib/subscription.js +0 -207
- package/lib/wrap.js +0 -332
- package/lib/xbot.js +0 -1576
- package/lib/xburner.js +0 -80
package/lib/pay.js
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import { Connection
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
56
|
-
*
|
|
57
|
-
* the
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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(
|
|
165
|
-
|
|
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
|
|
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
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
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
|
|
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
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
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
|
-
|
|
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 || {}),
|
|
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 =
|
|
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
|
|
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:
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
|
1565
|
-
//
|
|
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 =
|
|
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}`);
|