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.
- package/lib/chatgpt-spend.js +21 -0
- package/lib/evm.js +1 -1
- package/lib/funding-status.js +17 -0
- package/lib/info.js +8 -10
- package/lib/pay.js +17 -12
- package/lib/payment-error.js +15 -0
- package/lib/proxy.js +54 -63
- package/lib/stripeOnramp.js +11 -15
- package/lib/x402.js +3 -2
- package/package.json +2 -2
package/lib/chatgpt-spend.js
CHANGED
|
@@ -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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
/**
|
|
153
|
-
export function orderCandidatesByMemory(cands
|
|
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
|
-
|
|
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(() =>
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
|
523
|
-
|
|
524
|
-
lastPrices = await quotedPrices();
|
|
519
|
+
if (Date.now() - pricesAt < 60000) return lastPrices;
|
|
520
|
+
if (!pricesInflight) {
|
|
525
521
|
pricesAt = Date.now();
|
|
526
|
-
|
|
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(/
|
|
1072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/lib/stripeOnramp.js
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
addr
|
|
104
|
-
|
|
105
|
-
|
|
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 =
|
|
155
|
-
?
|
|
156
|
-
: '
|
|
157
|
-
|
|
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 `${
|
|
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.
|
|
4
|
-
"description": "Local x402-paying proxy + MCP server for openzoo.fun
|
|
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": {
|