openzoo 0.51.6 → 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/README.md +2 -0
- package/bin/openzoo.js +2 -0
- package/lib/chatgpt-spend.js +206 -0
- package/lib/chatgpt.js +12 -3
- package/lib/evm.js +1 -1
- package/lib/funding-status.js +17 -0
- package/lib/info.js +8 -10
- package/lib/ozSpendChip.js +2 -2
- 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/README.md
CHANGED
|
@@ -35,6 +35,8 @@ Configuration defaults to `~/.openzoo/chatgpt/config.toml`, with a separate desk
|
|
|
35
35
|
|
|
36
36
|
`--model <id>` overrides `deepseek-v4-flash`; `--bearer` stores the non-secret placeholder token instead of requiring an environment variable. `--no-launch` writes config only (no download, install, proxy, or launch); `--no-proxy` skips proxy startup; `--print` previews without side effects. Pass app flags after `--`, for example `openzoo chatgpt -- --ozone-platform=wayland`. `OPENZOO_CHATGPT_BIN` selects an explicit executable. Failed downloads, installation, or launch commands return an error.
|
|
37
37
|
|
|
38
|
+
The **savings pill** is enabled by default: an orange, draggable overlay shows spent and saved, with estimated direct cost in its expanded view. These are **all-time shared proxy totals**, not per-task ChatGPT billing; ordinary ChatGPT chats are excluded. Negative savings appear as extra cost, and unavailable totals are marked disconnected. The bridge reads `/v1/info` outside the renderer and updates only its own overlay, without modifying the signed app or its security policy. It uses a loopback-only, dynamically assigned Chromium debugging port on the OpenZoo desktop profile, exits after the app is gone for 60 seconds, and logs to `<CODEX_HOME>/desktop/openzoo-savings.log`. Use `--no-pill` to launch without the overlay/debugging flag. Run the command again to attach the pill after a separate app restart.
|
|
39
|
+
|
|
38
40
|
Installer references: [ChatGPT downloads](https://chatgpt.com/download/), [Windows MSIX deployment](https://learn.chatgpt.com/docs/enterprise/windows-deployment), [Linux packages](https://learn.chatgpt.com/docs/linux/linux-app).
|
|
39
41
|
|
|
40
42
|
|
package/bin/openzoo.js
CHANGED
|
@@ -155,6 +155,8 @@ usage:
|
|
|
155
155
|
Choose "sign in with API key" (sk-openzoo) if prompted.
|
|
156
156
|
--deb <file> (Linux) --config /path/to/config.toml
|
|
157
157
|
--model <id> (deepseek-v4-flash) --wire responses|chat --bearer
|
|
158
|
+
Savings pill: live shared proxy spend / direct estimate / saved
|
|
159
|
+
--no-pill disables the overlay
|
|
158
160
|
--no-launch (config only) --no-proxy --tunnel --print
|
|
159
161
|
-- <app flags>
|
|
160
162
|
npx openzoo openclaw write the zoo into ~/.openclaw/openclaw.json as a model
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Read-only savings overlay for the OpenZoo ChatGPT profile. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { cdpSession, listCdpPages } from './ozSpendChip.js';
|
|
8
|
+
|
|
9
|
+
export function chatGptSpendState(info) {
|
|
10
|
+
if (info?.youAreTalkingTo !== 'openzoo proxy') throw new Error('not an OpenZoo proxy');
|
|
11
|
+
const amount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
12
|
+
const spent = amount(info.spendUsd), would = amount(info.directUsd);
|
|
13
|
+
const comparable = spent !== null && would !== null && (would > 0 || spent === 0);
|
|
14
|
+
// Do not use savedUsd: older proxies clamp negative savings to zero.
|
|
15
|
+
const saved = comparable ? would - spent : null;
|
|
16
|
+
return { spent, would: comparable ? would : null, saved,
|
|
17
|
+
pct: comparable && would > 0 ? 100 * saved / would : null,
|
|
18
|
+
scope: 'All-time · shared OpenZoo proxy', updatedAt: Date.now(), online: true };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Self-contained renderer. Only owns its shadow-root overlay, never chat content. */
|
|
22
|
+
export function renderChatGptSpend(state) {
|
|
23
|
+
let host = document.getElementById('openzoo-chatgpt-spend');
|
|
24
|
+
if (!document.body) return false;
|
|
25
|
+
if (!host) {
|
|
26
|
+
host = document.createElement('div');
|
|
27
|
+
host.id = 'openzoo-chatgpt-spend';
|
|
28
|
+
host.style.cssText = 'position:fixed;right:20px;bottom:90px;z-index:2147483646;max-width:calc(100vw - 24px);-webkit-app-region:no-drag';
|
|
29
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
30
|
+
root.innerHTML = `<style>
|
|
31
|
+
:host{font:12px/1.5 system-ui,sans-serif;color:#fafafa;color-scheme:dark}
|
|
32
|
+
details{max-width:min(470px,calc(100vw - 24px))}
|
|
33
|
+
summary{list-style:none;cursor:grab;touch-action:none;border-radius:999px;background:#e85d1c;color:white;padding:8px 14px;box-shadow:0 4px 18px #0006;font-weight:650;overflow-wrap:anywhere}
|
|
34
|
+
summary::-webkit-details-marker{display:none}summary:focus-visible{outline:2px solid white;outline-offset:3px}
|
|
35
|
+
section{margin-top:8px;padding:12px 14px;border:1px solid #ffffff24;border-radius:14px;background:#18181bef;box-shadow:0 4px 18px #0005}
|
|
36
|
+
dl{display:grid;grid-template-columns:1fr auto;gap:5px 24px;margin:10px 0}dd{margin:0;font-variant-numeric:tabular-nums}
|
|
37
|
+
small{display:block;color:#bfbfc7}p{margin:8px 0 0;color:#bfbfc7}button{margin-top:10px;border:1px solid #ffffff30;border-radius:8px;background:transparent;color:inherit;padding:4px 10px;cursor:pointer}
|
|
38
|
+
</style><details><summary role="button" aria-expanded="false" aria-label="OpenZoo costs and savings"></summary><section>
|
|
39
|
+
<small data-scope></small><dl><dt>Spent</dt><dd data-spent></dd><dt>Would’ve cost · estimate</dt><dd data-would></dd><dt data-saving-label>Saved</dt><dd data-saved></dd></dl>
|
|
40
|
+
<small data-status role="status"></small><p>Direct cost is the proxy’s counterfactual estimate. Totals include other apps using this proxy. Ordinary ChatGPT chats are not included.</p>
|
|
41
|
+
<button type="button">Reset position</button></section></details>`;
|
|
42
|
+
const summary = root.querySelector('summary');
|
|
43
|
+
let drag = null, moved = false;
|
|
44
|
+
const place = (left, top) => {
|
|
45
|
+
const r = host.getBoundingClientRect();
|
|
46
|
+
host.style.left = Math.max(8, Math.min(left, innerWidth - r.width - 8)) + 'px';
|
|
47
|
+
host.style.top = Math.max(8, Math.min(top, innerHeight - r.height - 8)) + 'px';
|
|
48
|
+
host.style.right = host.style.bottom = 'auto';
|
|
49
|
+
};
|
|
50
|
+
document.body.appendChild(host);
|
|
51
|
+
try { const p = JSON.parse(localStorage.getItem('openzoo-chatgpt-spend-position')); if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) place(p.x, p.y); } catch { /* storage unavailable */ }
|
|
52
|
+
summary.addEventListener('pointerdown', e => {
|
|
53
|
+
if (e.button !== 0) return;
|
|
54
|
+
const r = host.getBoundingClientRect();
|
|
55
|
+
drag = { x: e.clientX, y: e.clientY, left: r.left, top: r.top }; moved = false;
|
|
56
|
+
summary.setPointerCapture(e.pointerId);
|
|
57
|
+
});
|
|
58
|
+
summary.addEventListener('pointermove', e => {
|
|
59
|
+
if (!drag) return;
|
|
60
|
+
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
|
61
|
+
if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
|
62
|
+
if (moved) place(drag.left + dx, drag.top + dy);
|
|
63
|
+
});
|
|
64
|
+
summary.addEventListener('pointerup', () => {
|
|
65
|
+
if (drag && moved) { const r = host.getBoundingClientRect(); try { localStorage.setItem('openzoo-chatgpt-spend-position', JSON.stringify({ x: r.left, y: r.top })); } catch {} }
|
|
66
|
+
drag = null;
|
|
67
|
+
});
|
|
68
|
+
summary.addEventListener('pointercancel', () => { drag = null; });
|
|
69
|
+
summary.addEventListener('click', e => { if (moved) { e.preventDefault(); moved = false; } });
|
|
70
|
+
root.querySelector('button').addEventListener('click', () => {
|
|
71
|
+
try { localStorage.removeItem('openzoo-chatgpt-spend-position'); } catch {}
|
|
72
|
+
host.style.left = host.style.top = 'auto'; host.style.right = '20px'; host.style.bottom = '90px';
|
|
73
|
+
});
|
|
74
|
+
root.querySelector('details').addEventListener('toggle', () => { summary.setAttribute('aria-expanded', String(root.querySelector('details').open)); if (host.style.left !== 'auto' && host.style.left) { const r = host.getBoundingClientRect(); place(r.left, r.top); } });
|
|
75
|
+
addEventListener('resize', () => { if (host.isConnected && host.style.left !== 'auto' && host.style.left) { const r = host.getBoundingClientRect(); place(r.left, r.top); } });
|
|
76
|
+
// A dead bridge must not leave apparently live numbers indefinitely.
|
|
77
|
+
const timer = setInterval(() => {
|
|
78
|
+
if (!host.isConnected) { clearInterval(timer); return; }
|
|
79
|
+
if (Date.now() - (host.__ozUpdate || 0) > 12000) {
|
|
80
|
+
root.querySelector('[data-status]').textContent = 'Disconnected · last known totals';
|
|
81
|
+
root.querySelector('summary').textContent = 'OpenZoo · disconnected';
|
|
82
|
+
}
|
|
83
|
+
}, 3000);
|
|
84
|
+
}
|
|
85
|
+
const root = host.shadowRoot;
|
|
86
|
+
if (!root) return false;
|
|
87
|
+
const usd = n => n === null || !Number.isFinite(n) ? '—' : '$' + Math.abs(n).toFixed(Math.abs(n) < 0.01 ? 4 : 2);
|
|
88
|
+
const more = state.saved !== null && state.saved < 0;
|
|
89
|
+
const savings = state.saved === null ? 'Savings —' : `${more ? 'Extra cost' : 'Saved'} ${usd(state.saved)}`;
|
|
90
|
+
root.querySelector('summary').textContent = state.online ? `OpenZoo · ${usd(state.spent)} spent · ${savings}` : 'OpenZoo · disconnected';
|
|
91
|
+
root.querySelector('[data-spent]').textContent = usd(state.spent);
|
|
92
|
+
root.querySelector('[data-would]').textContent = usd(state.would);
|
|
93
|
+
root.querySelector('[data-saving-label]').textContent = more ? 'Extra cost' : 'Saved';
|
|
94
|
+
root.querySelector('[data-saved]').textContent = usd(state.saved) + (state.pct === null ? '' : ` (${Math.abs(state.pct).toFixed(1)}%)`);
|
|
95
|
+
root.querySelector('[data-scope]').textContent = state.scope;
|
|
96
|
+
root.querySelector('[data-status]').textContent = state.online ? 'Updated ' + new Date(state.updatedAt).toLocaleTimeString() : 'Proxy unavailable · last known totals';
|
|
97
|
+
host.__ozUpdate = Date.now();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function chatGptSpendSource(state) {
|
|
102
|
+
return `(${renderChatGptSpend.toString()})(${JSON.stringify(state).replace(/</g, '\\u003c')})`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function readChatGptDebugPort(profile) {
|
|
106
|
+
try {
|
|
107
|
+
const n = Number(fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split(/\r?\n/)[0]);
|
|
108
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
|
|
109
|
+
} catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isChatGptRenderer(target, port) {
|
|
113
|
+
try {
|
|
114
|
+
const ws = new URL(target.webSocketDebuggerUrl);
|
|
115
|
+
if (ws.protocol !== 'ws:' || ws.hostname !== '127.0.0.1' && ws.hostname !== 'localhost' || Number(ws.port) !== port) return false;
|
|
116
|
+
const url = new URL(target.url);
|
|
117
|
+
return target.type === 'page' && (url.protocol === 'file:' && /\/index\.html$/.test(url.pathname) || url.protocol === 'app:' && /codex|chatgpt/.test(url.hostname));
|
|
118
|
+
} catch { return false; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Host-side polling avoids changing the signed app or relaxing renderer CSP. */
|
|
122
|
+
export async function watchChatGptSpend({ profile, proxyPort, fetchImpl = fetch, connect = cdpSession,
|
|
123
|
+
sleep = ms => new Promise(r => setTimeout(r, ms)), stopped = () => false, log = console.error } = {}) {
|
|
124
|
+
const sessions = new Map();
|
|
125
|
+
let misses = 0;
|
|
126
|
+
let state = { spent: null, would: null, saved: null, pct: null, scope: 'All-time · shared OpenZoo proxy', online: false, updatedAt: 0 };
|
|
127
|
+
try {
|
|
128
|
+
while (!stopped() && misses < 20) {
|
|
129
|
+
const port = readChatGptDebugPort(profile);
|
|
130
|
+
const pages = port ? (await listCdpPages(port, fetchImpl)).filter(t => isChatGptRenderer(t, port)) : [];
|
|
131
|
+
misses = pages.length ? 0 : misses + 1;
|
|
132
|
+
const live = new Set(pages.map(t => t.webSocketDebuggerUrl));
|
|
133
|
+
for (const [id, session] of sessions) if (!live.has(id)) { session.close(); sessions.delete(id); }
|
|
134
|
+
try {
|
|
135
|
+
const r = await fetchImpl(`http://127.0.0.1:${proxyPort}/v1/info`, { signal: AbortSignal.timeout(2000) });
|
|
136
|
+
if (!r.ok) throw new Error(`proxy HTTP ${r.status}`);
|
|
137
|
+
state = chatGptSpendState(await r.json());
|
|
138
|
+
} catch { state = { ...state, online: false }; }
|
|
139
|
+
for (const page of pages) {
|
|
140
|
+
const id = page.webSocketDebuggerUrl;
|
|
141
|
+
try {
|
|
142
|
+
if (!sessions.has(id)) sessions.set(id, await connect(id, { timeoutMs: 2500, sendOrigin: false }));
|
|
143
|
+
const result = await sessions.get(id).send('Runtime.evaluate', { expression: chatGptSpendSource(state), returnByValue: true });
|
|
144
|
+
if (result.exceptionDetails) throw new Error('savings pill renderer evaluation failed');
|
|
145
|
+
} catch (e) { sessions.get(id)?.close(); sessions.delete(id); log(`openzoo: savings pill: ${e.message}`); }
|
|
146
|
+
}
|
|
147
|
+
await sleep(3000);
|
|
148
|
+
}
|
|
149
|
+
if (misses >= 20) log('openzoo: savings pill stopped: no ChatGPT renderer found for 60s');
|
|
150
|
+
} finally { for (const session of sessions.values()) session.close(); }
|
|
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
|
+
|
|
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
|
+
}
|
|
174
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
175
|
+
const fd = fs.openSync(path.join(profile, 'openzoo-savings.log'), 'a', 0o600);
|
|
176
|
+
try {
|
|
177
|
+
const child = spawnImpl(process.execPath, [fileURLToPath(import.meta.url), '--watch', profile, String(proxyPort)], { detached: true, stdio: ['ignore', fd, fd] });
|
|
178
|
+
await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
|
|
179
|
+
child.unref();
|
|
180
|
+
} finally { fs.closeSync(fd); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One watcher per profile, including concurrent launcher invocations. */
|
|
184
|
+
export function claimSpendWatcher(profile) {
|
|
185
|
+
const lock = path.join(profile, 'openzoo-savings.lock');
|
|
186
|
+
try {
|
|
187
|
+
const pid = Number(fs.readFileSync(lock, 'utf8'));
|
|
188
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
189
|
+
try { process.kill(pid, 0); return null; } catch (e) { if (e.code !== 'ESRCH') return null; }
|
|
190
|
+
} else if (Date.now() - fs.statSync(lock).mtimeMs < 60000) return null;
|
|
191
|
+
fs.unlinkSync(lock);
|
|
192
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
193
|
+
let fd;
|
|
194
|
+
try { fd = fs.openSync(lock, 'wx', 0o600); } catch (e) { if (e.code === 'EEXIST') return null; throw e; }
|
|
195
|
+
fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
|
|
196
|
+
return () => { try { if (fs.readFileSync(lock, 'utf8') === String(process.pid)) fs.unlinkSync(lock); } catch {} };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--watch') {
|
|
200
|
+
const release = claimSpendWatcher(process.argv[3]);
|
|
201
|
+
if (release) {
|
|
202
|
+
process.once('exit', release);
|
|
203
|
+
watchChatGptSpend({ profile: process.argv[3], proxyPort: Number(process.argv[4]) })
|
|
204
|
+
.catch(e => { console.error(e.message); process.exitCode = 1; }).finally(release);
|
|
205
|
+
}
|
|
206
|
+
}
|
package/lib/chatgpt.js
CHANGED
|
@@ -11,6 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { config } from './config.js';
|
|
14
|
+
import { startChatGptSpend } from './chatgpt-spend.js';
|
|
14
15
|
import { installChatGpt, windowsChatGptApp, runCommand } from './chatgpt-install.js';
|
|
15
16
|
|
|
16
17
|
export const PROVIDER_ID = 'openzoo';
|
|
@@ -245,9 +246,9 @@ async function ensureProxy({ tunnel }) {
|
|
|
245
246
|
export async function setupChatGpt(argv = [], dependencies = {}) {
|
|
246
247
|
const { platform = process.platform, home = os.homedir(), env: sourceEnv = process.env,
|
|
247
248
|
resolve = resolveChatGptApp, install = installChatGpt, proxy = ensureProxy,
|
|
248
|
-
launch = launchChatGpt, findWindows = windowsChatGptApp } = dependencies;
|
|
249
|
+
launch = launchChatGpt, findWindows = windowsChatGptApp, overlay = startChatGptSpend } = dependencies;
|
|
249
250
|
const args = [...argv];
|
|
250
|
-
const opt = { model: null, deb: null, wireApi: 'responses', bearer: false, launch: true, proxy: true, tunnel: false, print: false, configPath: null, rest: [] };
|
|
251
|
+
const opt = { model: null, deb: null, wireApi: 'responses', bearer: false, launch: true, proxy: true, pill: true, tunnel: false, print: false, configPath: null, rest: [] };
|
|
251
252
|
while (args.length) {
|
|
252
253
|
const a = args.shift();
|
|
253
254
|
if (a === '--') { opt.rest.push(...args); break; }
|
|
@@ -261,6 +262,7 @@ export async function setupChatGpt(argv = [], dependencies = {}) {
|
|
|
261
262
|
else if (a === '--config') opt.configPath = value();
|
|
262
263
|
else if (a === '--bearer') opt.bearer = true;
|
|
263
264
|
else if (a === '--no-launch') opt.launch = false;
|
|
265
|
+
else if (a === '--no-pill') opt.pill = false;
|
|
264
266
|
else if (a === '--no-proxy') opt.proxy = false;
|
|
265
267
|
else if (a === '--tunnel') opt.tunnel = true;
|
|
266
268
|
else if (a === '--print') opt.print = true;
|
|
@@ -319,7 +321,14 @@ export async function setupChatGpt(argv = [], dependencies = {}) {
|
|
|
319
321
|
console.log(`launching ${app}`);
|
|
320
322
|
console.log(` Codex / agent mode → the zoo, paid per turn by x402. Choose "sign in with API key" (any key).`);
|
|
321
323
|
console.log(' the plain ChatGPT chat tab still talks to chatgpt.com — that half is not API-shaped.');
|
|
322
|
-
|
|
324
|
+
const appArgs = opt.pill ? ['--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', ...opt.rest] : opt.rest;
|
|
325
|
+
await launch(app, appArgs, { env, platform });
|
|
326
|
+
if (opt.pill) {
|
|
327
|
+
try {
|
|
328
|
+
await overlay({ profile: env.CODEX_ELECTRON_USER_DATA_PATH, proxyPort: config.port });
|
|
329
|
+
console.log('savings pill enabled · spent / estimated direct cost / saved · shared proxy totals');
|
|
330
|
+
} catch (e) { console.error(`openzoo: savings pill unavailable: ${e.message}`); }
|
|
331
|
+
}
|
|
323
332
|
return { file, model, app, launched: true };
|
|
324
333
|
}
|
|
325
334
|
|
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/ozSpendChip.js
CHANGED
|
@@ -758,7 +758,7 @@ function decodeWsFrames(buf) {
|
|
|
758
758
|
return { frames, rest };
|
|
759
759
|
}
|
|
760
760
|
|
|
761
|
-
export function cdpSession(wsUrl, { timeoutMs = 8000 } = {}) {
|
|
761
|
+
export function cdpSession(wsUrl, { timeoutMs = 8000, sendOrigin = true } = {}) {
|
|
762
762
|
return new Promise((resolve, reject) => {
|
|
763
763
|
let u;
|
|
764
764
|
try { u = new URL(wsUrl); } catch (e) { reject(e); return; }
|
|
@@ -793,7 +793,7 @@ export function cdpSession(wsUrl, { timeoutMs = 8000 } = {}) {
|
|
|
793
793
|
+ 'Connection: Upgrade\r\n'
|
|
794
794
|
+ 'Sec-WebSocket-Version: 13\r\n'
|
|
795
795
|
+ `Sec-WebSocket-Key: ${key}\r\n`
|
|
796
|
-
+ `Origin: http://${u.host}\r\n`
|
|
796
|
+
+ (sendOrigin ? `Origin: http://${u.host}\r\n` : '')
|
|
797
797
|
+ '\r\n',
|
|
798
798
|
);
|
|
799
799
|
});
|
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": {
|