openzoo 0.49.11 → 0.49.13
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/bin/openzoo.js +19 -0
- package/lib/launch.js +18 -12
- package/lib/namespace.js +13 -7
- package/lib/openclaw.js +196 -0
- package/lib/pay.js +38 -7
- package/lib/wrap.js +16 -2
- package/lib/xbot.js +1576 -0
- package/lib/xburner.js +80 -0
- package/package.json +1 -1
- package/vendor/modelroute/FOR_MOOSE.md +110 -0
package/bin/openzoo.js
CHANGED
|
@@ -103,6 +103,8 @@ usage:
|
|
|
103
103
|
npx openzoo vscode [path] same, for VS Code
|
|
104
104
|
npx openzoo editor [path] whichever is installed (Cursor wins if both)
|
|
105
105
|
npx openzoo claude [dir] Claude Code CLI on the zoo (x402 per turn); --desktop for the app
|
|
106
|
+
npx openzoo xbot @openzoobot on X: 1 free question per account, then x402
|
|
107
|
+
--once (single poll) --dry-run (answer, do not post)
|
|
106
108
|
by default, --terminal for the Claude Code CLI
|
|
107
109
|
npx openzoo launch <cmd> [args] launch a TERMINAL Messages API client
|
|
108
110
|
(claude, aider...) already pointed at the zoo
|
|
@@ -117,6 +119,11 @@ usage:
|
|
|
117
119
|
and launches Grok Bot with Node TLS override (sudo required;
|
|
118
120
|
ctrl-c restores /etc/hosts).
|
|
119
121
|
--no-takeover plain launch · --no-launch config only
|
|
122
|
+
npx openzoo openclaw write the zoo into ~/.openclaw/openclaw.json as a model
|
|
123
|
+
provider WITH REAL PRICES (OpenClaw's own custom-provider
|
|
124
|
+
path hard-codes $0.00 and ignores /v1/models pricing)
|
|
125
|
+
--all (whole catalog) --models a,b (exact ids)
|
|
126
|
+
--default <id> (set the agents' primary model)
|
|
120
127
|
npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_bind, zoo_models, zoo_wallet, zoo_contexts)
|
|
121
128
|
npx openzoo unblock restore the editor's own backend in the hosts file
|
|
122
129
|
npx openzoo tunnel public-url-only mode (everything key-gated, no keyless localhost)
|
|
@@ -172,9 +179,21 @@ async function main() {
|
|
|
172
179
|
// is enough; no patching of the app bundle.
|
|
173
180
|
await (await import('../lib/grokcli.js')).setupGrokBot(process.argv.slice(3));
|
|
174
181
|
break;
|
|
182
|
+
case 'openclaw':
|
|
183
|
+
await (await import('../lib/openclaw.js')).setupOpenClaw(process.argv.slice(3));
|
|
184
|
+
break;
|
|
175
185
|
case 'mcp':
|
|
176
186
|
await (await import('../lib/mcp.js')).startMcp();
|
|
177
187
|
break;
|
|
188
|
+
case 'xbot': {
|
|
189
|
+
const rest = process.argv.slice(3);
|
|
190
|
+
await (await import('../lib/xbot.js')).runXBot({
|
|
191
|
+
once: rest.includes('--once'),
|
|
192
|
+
dryRun: rest.includes('--dry-run'),
|
|
193
|
+
seed: rest.includes('--seed'),
|
|
194
|
+
});
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
178
197
|
case 'claude':
|
|
179
198
|
await (await import('../lib/launch.js')).launchClaude(process.argv.slice(3));
|
|
180
199
|
break;
|
package/lib/launch.js
CHANGED
|
@@ -280,18 +280,22 @@ export async function launchClaude(argv) {
|
|
|
280
280
|
|
|
281
281
|
if (terminal) {
|
|
282
282
|
const rejected = [];
|
|
283
|
-
|
|
283
|
+
let cli = resolveClaudeCli(process.env, rejected);
|
|
284
|
+
if (!cli && rejected.length) {
|
|
285
|
+
// THE FORMAT CHECK IS A TIE-BREAKER, NOT A VETO.
|
|
286
|
+
//
|
|
287
|
+
// isRunnableExecutable() knows four magics. Something legitimate that it
|
|
288
|
+
// has never seen — a packaging format we did not anticipate — would be
|
|
289
|
+
// refused here even though the OS would have run it happily, and we would
|
|
290
|
+
// have told the user to reinstall a CLI that was fine. When there is a
|
|
291
|
+
// real alternative the check earns its keep by preferring the good file;
|
|
292
|
+
// when this is the ONLY candidate, defer to the kernel and let the spawn
|
|
293
|
+
// below decide. A wrong heuristic must never be the sole reason we refuse.
|
|
294
|
+
cli = rejected[0];
|
|
295
|
+
console.error(`openzoo: ${cli} does not look like a runnable executable — trying it anyway.`);
|
|
296
|
+
}
|
|
284
297
|
if (!cli) {
|
|
285
|
-
|
|
286
|
-
// Found it, could not run it. Say so — "not found on PATH" is a lie
|
|
287
|
-
// here and sends the user off to reinstall something they already have.
|
|
288
|
-
console.error('openzoo: found `claude` but it is not a runnable executable:');
|
|
289
|
-
for (const f of rejected) console.error(` ${f}`);
|
|
290
|
-
console.error(' (no shebang and no binary magic — usually a truncated or half-finished install)');
|
|
291
|
-
console.error(' fix: reinstall Claude Code, or delete the broken file so another copy on PATH is used.');
|
|
292
|
-
} else {
|
|
293
|
-
console.error('openzoo: `claude` CLI not found on PATH — install Claude Code, or drop --terminal for the desktop app');
|
|
294
|
-
}
|
|
298
|
+
console.error('openzoo: `claude` CLI not found on PATH — install Claude Code, or drop --terminal for the desktop app');
|
|
295
299
|
process.exit(1);
|
|
296
300
|
}
|
|
297
301
|
// ALWAYS-ON HUD via Claude Code's NATIVE status line (the title bar is owned
|
|
@@ -422,9 +426,11 @@ export async function launchClaude(argv) {
|
|
|
422
426
|
try { restoreStatus?.(); } catch { /* ignore */ }
|
|
423
427
|
console.error(`openzoo: cannot execute ${cli} (${e?.code || e?.message})`);
|
|
424
428
|
console.error(' it is marked executable but the OS refused to run it.');
|
|
425
|
-
console.error(` check:
|
|
429
|
+
console.error(` check: file ${cli}`);
|
|
430
|
+
console.error(` head -c 2 ${cli} | xxd # a healthy script starts 2321 ("#!")`);
|
|
426
431
|
console.error(' usually a truncated install, a wrong-architecture binary,');
|
|
427
432
|
console.error(' or a shebang pointing at an interpreter that no longer exists.');
|
|
433
|
+
console.error(' fix: npm i -g @anthropic-ai/claude-code');
|
|
428
434
|
process.exit(1);
|
|
429
435
|
}
|
|
430
436
|
child.on('exit', (c) => { try { restoreStatus?.(); } catch { /* ignore */ } process.exit(c ?? 0); });
|
package/lib/namespace.js
CHANGED
|
@@ -66,11 +66,11 @@ import { loadOrCreateWallet } from './wallet.js';
|
|
|
66
66
|
*/
|
|
67
67
|
export const STACC_NAMESPACE = 'stacc';
|
|
68
68
|
|
|
69
|
-
export function namespaceHeaderValue() {
|
|
69
|
+
export function namespaceHeaderValue(wallet) {
|
|
70
70
|
try {
|
|
71
71
|
// Still require a wallet: the namespace is meaningless without a signer to
|
|
72
72
|
// prove it, and sending one unsigned drops you into the SHARED tenant.
|
|
73
|
-
loadOrCreateWallet();
|
|
73
|
+
if (!wallet?.keypair) loadOrCreateWallet();
|
|
74
74
|
return STACC_NAMESPACE;
|
|
75
75
|
} catch {
|
|
76
76
|
return ''; // no wallet (read-only use): fall back to the shared tenant
|
|
@@ -96,8 +96,14 @@ export function namespaceHeaderValue() {
|
|
|
96
96
|
*/
|
|
97
97
|
const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
98
98
|
|
|
99
|
-
function signNamespace(namespace) {
|
|
100
|
-
|
|
99
|
+
function signNamespace(namespace, wallet) {
|
|
100
|
+
// SIGN WITH THE WALLET THAT IS PAYING, not with whatever this machine owns.
|
|
101
|
+
// The gateway derives the tenant from the PROVEN signer, and credit is held
|
|
102
|
+
// per tenant — so signing every burner's request with the operator's machine
|
|
103
|
+
// key put all of them in the operator's tenant and let them spend the
|
|
104
|
+
// operator's gateway credit. MEASURED: an empty burner got `paid: "credit"`
|
|
105
|
+
// and a 200, with no 402 ever issued.
|
|
106
|
+
const w = wallet?.keypair ? wallet : loadOrCreateWallet();
|
|
101
107
|
const timestamp = String(Date.now());
|
|
102
108
|
// Node has no raw-ed25519 signer, but it will build one from a PKCS8 DER
|
|
103
109
|
// wrapper around the 32-byte seed (a Solana secretKey is seed||pubkey).
|
|
@@ -113,13 +119,13 @@ function signNamespace(namespace) {
|
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
/** Merge the namespace header — and its proof — into any headers object. */
|
|
116
|
-
export function withNamespace(headers = {}) {
|
|
117
|
-
const ns = namespaceHeaderValue();
|
|
122
|
+
export function withNamespace(headers = {}, wallet) {
|
|
123
|
+
const ns = namespaceHeaderValue(wallet);
|
|
118
124
|
if (!ns) return headers;
|
|
119
125
|
// No unsigned fallback. The gateway REQUIRES the signature, so a bare
|
|
120
126
|
// namespace buys nothing — it would just be silently demoted to the shared
|
|
121
127
|
// tenant, which looks like "my corpus vanished" instead of a clear failure.
|
|
122
|
-
return { ...headers, 'x-openzoo-namespace': ns, ...signNamespace(ns) };
|
|
128
|
+
return { ...headers, 'x-openzoo-namespace': ns, ...signNamespace(ns, wallet) };
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
// Bitcoin-alphabet base58, matching what the gateway's bs58 decode expects.
|
package/lib/openclaw.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx openzoo openclaw` — write the zoo into OpenClaw's config WITH REAL
|
|
3
|
+
* PRICES, because OpenClaw will never learn them from the wire.
|
|
4
|
+
*
|
|
5
|
+
* MEASURED, on OpenClaw 2026.7.1-2: its custom-provider discovery hard-codes
|
|
6
|
+
* `cost: {input:0, output:0, ...}` (SELF_HOSTED_DEFAULT_COST in
|
|
7
|
+
* provider-self-hosted-setup) and ignores both the OpenRouter-style `pricing`
|
|
8
|
+
* field our /v1/models already serves and any per-response usage cost. Its
|
|
9
|
+
* cost panel prices turns purely from the `cost` block in
|
|
10
|
+
* ~/.openclaw/openclaw.json — so a hand-added provider shows $0.00 forever.
|
|
11
|
+
* The OpenRouter pricing parser it DOES have (parseOpenRouterPricing,
|
|
12
|
+
* value * 1e6 → USD per Mtok) is wired only to the first-party OpenRouter
|
|
13
|
+
* provider. Hence this command: fetch the live catalog, convert
|
|
14
|
+
* pricing.prompt/completion (USD/token) into OpenClaw's USD/Mtok `cost`
|
|
15
|
+
* blocks, and merge the provider into the config file ourselves.
|
|
16
|
+
*
|
|
17
|
+
* The written numbers are the CEILING (openrouter-direct basis): the gateway
|
|
18
|
+
* charges at most this, less with trailing volume and leCore context reuse.
|
|
19
|
+
* A client-side estimate can only be honest-or-high; receipts on the proxy
|
|
20
|
+
* console stay the ground truth.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { config } from './config.js';
|
|
26
|
+
import { fetchHeaders } from './fetch.js';
|
|
27
|
+
import {
|
|
28
|
+
quoteableRows, pickClaudePickerRows, displayNameFor, tokenPricePair, isAutoModel,
|
|
29
|
+
} from './models.js';
|
|
30
|
+
|
|
31
|
+
export const PROVIDER_KEY = 'openzoo';
|
|
32
|
+
|
|
33
|
+
/** ~/.openclaw/openclaw.json unless overridden (tests, ports of OpenClaw). */
|
|
34
|
+
export function openclawConfigPath() {
|
|
35
|
+
return process.env.OPENCLAW_CONFIG_PATH
|
|
36
|
+
|| path.join(os.homedir(), '.openclaw', 'openclaw.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reasoning flag only where the id says so unambiguously. A wrong `true`
|
|
41
|
+
* makes OpenClaw send thinking parameters the model rejects; a wrong `false`
|
|
42
|
+
* merely hides a toggle. Asymmetric costs → conservative test.
|
|
43
|
+
*/
|
|
44
|
+
export function isReasoningId(id) {
|
|
45
|
+
return /(^|\/)o[134](-|$)|reasoner|thinking|qwq|(^|[/-])r1($|[.-])/i.test(String(id || ''));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One catalog row → one OpenClaw model entry. Pricing arrives in USD per
|
|
50
|
+
* token (OpenRouter units); OpenClaw's `cost` is USD per MILLION tokens —
|
|
51
|
+
* same conversion its own OpenRouter parser does (value * 1e6).
|
|
52
|
+
*/
|
|
53
|
+
export function openclawModelEntry(row) {
|
|
54
|
+
const [prompt, completion] = tokenPricePair(row?.pricing);
|
|
55
|
+
const perM = (v) => (Number.isFinite(v) && v > 0 ? Number((v * 1e6).toFixed(6)) : 0);
|
|
56
|
+
return {
|
|
57
|
+
id: row.id,
|
|
58
|
+
name: displayNameFor(row.id) || row.id,
|
|
59
|
+
reasoning: isReasoningId(row.id),
|
|
60
|
+
input: ['text'],
|
|
61
|
+
// context_length is the CLIENT-USABLE ceiling (leCore auto-spill), which
|
|
62
|
+
// is the honest number for a harness deciding whether to chunk.
|
|
63
|
+
contextWindow: Number(row.context_length) > 0 ? Number(row.context_length) : 128000,
|
|
64
|
+
maxTokens: 8192,
|
|
65
|
+
cost: {
|
|
66
|
+
input: perM(prompt),
|
|
67
|
+
output: perM(completion),
|
|
68
|
+
cacheRead: 0,
|
|
69
|
+
cacheWrite: 0,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Merge the openzoo provider into an OpenClaw config object. Pure — takes and
|
|
76
|
+
* returns plain objects, touches no disk. Other providers and every unrelated
|
|
77
|
+
* key survive untouched; OUR provider block is replaced wholesale (it is
|
|
78
|
+
* generated, and a stale half-merge would resurrect dead models or prices).
|
|
79
|
+
* The agent default is only claimed when the user asked (`forceDefault`) or
|
|
80
|
+
* no primary model is configured at all — never silently re-pointed.
|
|
81
|
+
*/
|
|
82
|
+
export function mergeOpenClawConfig(existing, { port, entries, defaultId, forceDefault } = {}) {
|
|
83
|
+
const cfg = existing && typeof existing === 'object' ? existing : {};
|
|
84
|
+
cfg.models = cfg.models && typeof cfg.models === 'object' ? cfg.models : {};
|
|
85
|
+
cfg.models.providers = cfg.models.providers && typeof cfg.models.providers === 'object'
|
|
86
|
+
? cfg.models.providers : {};
|
|
87
|
+
cfg.models.providers[PROVIDER_KEY] = {
|
|
88
|
+
baseUrl: `http://localhost:${port}/v1`,
|
|
89
|
+
apiKey: 'sk-openzoo', // any value: the zoo takes payment, not keys
|
|
90
|
+
api: 'openai-completions',
|
|
91
|
+
models: entries,
|
|
92
|
+
};
|
|
93
|
+
let changedDefault = false;
|
|
94
|
+
const ref = defaultId ? `${PROVIDER_KEY}/${defaultId}` : null;
|
|
95
|
+
if (ref) {
|
|
96
|
+
cfg.agents = cfg.agents && typeof cfg.agents === 'object' ? cfg.agents : {};
|
|
97
|
+
cfg.agents.defaults = cfg.agents.defaults && typeof cfg.agents.defaults === 'object'
|
|
98
|
+
? cfg.agents.defaults : {};
|
|
99
|
+
const model = cfg.agents.defaults.model && typeof cfg.agents.defaults.model === 'object'
|
|
100
|
+
? cfg.agents.defaults.model : {};
|
|
101
|
+
if (forceDefault || !model.primary) {
|
|
102
|
+
model.primary = ref;
|
|
103
|
+
cfg.agents.defaults.model = model;
|
|
104
|
+
changedDefault = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { cfg, changedDefault };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function fetchCatalogRows() {
|
|
111
|
+
const r = await fetchHeaders(`${config.apiBase}/v1/models`);
|
|
112
|
+
if (!r.ok) throw new Error(`model catalog fetch failed: HTTP ${r.status}`);
|
|
113
|
+
const d = await r.json();
|
|
114
|
+
return quoteableRows(d.data).filter((m) => !isAutoModel(m.id));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pickRows(rows, { all, wanted }) {
|
|
118
|
+
if (wanted && wanted.length) {
|
|
119
|
+
const byId = new Map(rows.map((m) => [m.id, m]));
|
|
120
|
+
const missing = wanted.filter((id) => !byId.has(id));
|
|
121
|
+
if (missing.length) {
|
|
122
|
+
throw new Error(`not in the live catalog: ${missing.join(', ')} (see: npx openzoo models)`);
|
|
123
|
+
}
|
|
124
|
+
return wanted.map((id) => byId.get(id));
|
|
125
|
+
}
|
|
126
|
+
if (all) return rows;
|
|
127
|
+
// Short honest default: same flagship picker Claude Code gets, minus Auto
|
|
128
|
+
// (Auto's price varies per route — a fixed cost block would be a lie).
|
|
129
|
+
const picked = pickClaudePickerRows(rows).filter((m) => !isAutoModel(m.id));
|
|
130
|
+
return picked.length ? picked : rows.slice(0, 8);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `npx openzoo openclaw [--all | --models a,b] [--default <id>] [--config <path>]`
|
|
135
|
+
*/
|
|
136
|
+
export async function setupOpenClaw(argv = []) {
|
|
137
|
+
const args = [...argv];
|
|
138
|
+
const opt = { all: false, wanted: null, defaultId: null, configPath: null };
|
|
139
|
+
while (args.length) {
|
|
140
|
+
const a = args.shift();
|
|
141
|
+
if (a === '--all') opt.all = true;
|
|
142
|
+
else if (a === '--models') opt.wanted = String(args.shift() || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
143
|
+
else if (a === '--default') opt.defaultId = String(args.shift() || '').trim() || null;
|
|
144
|
+
else if (a === '--config') opt.configPath = String(args.shift() || '').trim() || null;
|
|
145
|
+
else throw new Error(`unknown flag: ${a}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rows = pickRows(await fetchCatalogRows(), opt);
|
|
149
|
+
if (!rows.length) throw new Error('live catalog returned no quoteable models');
|
|
150
|
+
const entries = rows.map(openclawModelEntry);
|
|
151
|
+
const defaultId = opt.defaultId || entries[0].id;
|
|
152
|
+
if (!entries.some((e) => e.id === defaultId)) {
|
|
153
|
+
throw new Error(`--default ${defaultId} is not among the written models (add it via --models)`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const file = opt.configPath || openclawConfigPath();
|
|
157
|
+
let existing = {};
|
|
158
|
+
if (fs.existsSync(file)) {
|
|
159
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
160
|
+
try {
|
|
161
|
+
existing = raw.trim() ? JSON.parse(raw) : {};
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// OpenClaw itself writes strict JSON; a parse failure means the user
|
|
164
|
+
// hand-edited (JSON5 comments etc.). Print the block instead of
|
|
165
|
+
// corrupting their file — fail open with a usable result.
|
|
166
|
+
console.error(`openzoo: could not parse ${file} (${e.message}).`);
|
|
167
|
+
console.error('add this under models.providers yourself:\n');
|
|
168
|
+
console.error(JSON.stringify({ [PROVIDER_KEY]: mergeOpenClawConfig({}, { port: config.port, entries, defaultId }).cfg.models.providers[PROVIDER_KEY] }, null, 2));
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
fs.copyFileSync(file, `${file}.openzoo-backup`);
|
|
173
|
+
} else {
|
|
174
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const { cfg, changedDefault } = mergeOpenClawConfig(existing, {
|
|
178
|
+
port: config.port,
|
|
179
|
+
entries,
|
|
180
|
+
defaultId,
|
|
181
|
+
forceDefault: Boolean(opt.defaultId),
|
|
182
|
+
});
|
|
183
|
+
fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
184
|
+
|
|
185
|
+
console.log(`wrote ${entries.length} zoo model(s) with real ceiling prices into ${file}`);
|
|
186
|
+
for (const e of entries) {
|
|
187
|
+
console.log(` ${PROVIDER_KEY}/${e.id} $${e.cost.input}/Mtok in, $${e.cost.output}/Mtok out`);
|
|
188
|
+
}
|
|
189
|
+
console.log(changedDefault
|
|
190
|
+
? `default model: ${PROVIDER_KEY}/${defaultId}`
|
|
191
|
+
: `default model kept (${cfg.agents?.defaults?.model?.primary || 'unset'}); use --default <id> to switch`);
|
|
192
|
+
console.log('prices are the CEILING (openrouter-direct): volume + context reuse only lower them;');
|
|
193
|
+
console.log('receipts on the proxy console remain the ground truth.');
|
|
194
|
+
console.log('\nnow: keep `npx openzoo` running, then `openclaw gateway restart`');
|
|
195
|
+
return { file, entries, defaultId, changedDefault };
|
|
196
|
+
}
|
package/lib/pay.js
CHANGED
|
@@ -152,8 +152,21 @@ export function solanaFundingEmpty(balances) {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
export class PayClient {
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
/**
|
|
156
|
+
* @param {{keypair?: import('@solana/web3.js').Keypair, evmPrivateKey?: string}} [burner]
|
|
157
|
+
* Spend from a SPECIFIC wallet instead of this machine's ~/.openzoo/wallet.json.
|
|
158
|
+
* @openzoobot needs this: the asker pays from their own derived burner, so
|
|
159
|
+
* one poller process settles from a different wallet per question. Without
|
|
160
|
+
* the override every X user's question would be paid by the operator's
|
|
161
|
+
* machine wallet — which is not "the asker pays", it is us paying for
|
|
162
|
+
* everyone and calling it x402.
|
|
163
|
+
*/
|
|
164
|
+
constructor(burner) {
|
|
165
|
+
// A derived burner is only ever used to make SOMEONE ELSE pay, so it
|
|
166
|
+
// implies x402-only. Defaulting this off would silently reintroduce the
|
|
167
|
+
// bug it exists to prevent.
|
|
168
|
+
this.noSubscription = Boolean(burner?.keypair) && burner?.allowSubscription !== true;
|
|
169
|
+
const w = burner?.keypair ? { ...burner, created: false, path: '(derived)' } : loadOrCreateWallet();
|
|
157
170
|
this.keypair = w.keypair;
|
|
158
171
|
this.evmPrivateKey = w.evmPrivateKey;
|
|
159
172
|
this.walletCreated = w.created;
|
|
@@ -358,13 +371,24 @@ export class PayClient {
|
|
|
358
371
|
if (short <= 0n) return {};
|
|
359
372
|
|
|
360
373
|
const { reserves, supply } = await poolState(this.connection, pool);
|
|
361
|
-
const
|
|
374
|
+
const minDeposit = depositForShares(short, reserves, supply);
|
|
362
375
|
const underlyingBal = attempt === 0
|
|
363
376
|
? underlyingNow
|
|
364
377
|
: await tokenBalance(this.connection, owner, pool.underlying.toBase58());
|
|
365
|
-
if (underlyingBal.raw <
|
|
378
|
+
if (underlyingBal.raw < minDeposit) {
|
|
366
379
|
throw new UnderfundedError(accept, underlyingBal.ui, this.address);
|
|
367
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;
|
|
368
392
|
|
|
369
393
|
onStage?.('funding');
|
|
370
394
|
const wrappedAta = getAssociatedTokenAddressSync(pool.wrapped, owner, false, pool.wrappedProgram);
|
|
@@ -377,7 +401,7 @@ export class PayClient {
|
|
|
377
401
|
// No SOL for a standalone conversion — bundle it into the payment tx.
|
|
378
402
|
const rentPayer = new PublicKey(accept.extra.feePayer);
|
|
379
403
|
return {
|
|
380
|
-
preInstructions: buildWrapInstructions({ pool, owner, depositRaw:
|
|
404
|
+
preInstructions: buildWrapInstructions({ pool, owner, depositRaw: minDeposit, rentPayer }),
|
|
381
405
|
};
|
|
382
406
|
}
|
|
383
407
|
const sig = await sendWrap(this.connection, this.keypair, pool, deposit);
|
|
@@ -399,12 +423,19 @@ export class PayClient {
|
|
|
399
423
|
onStage?.('request');
|
|
400
424
|
// Contexts are tenanted by this namespace server-side — a request without
|
|
401
425
|
// it cannot see corpora this wallet bound.
|
|
402
|
-
init = { ...init, headers: withNamespace(init.headers || {}) };
|
|
426
|
+
init = { ...init, headers: withNamespace(init.headers || {}, { keypair: this.keypair }) };
|
|
403
427
|
// Subscription key · no x402. A stored Stripe key is a bearer on the zoo
|
|
404
428
|
// API (same as the public /billing/done snippet). Wallet/x402 stays if
|
|
405
429
|
// there is no key, or if the gateway still answers 402.
|
|
406
|
-
|
|
430
|
+
// `noSubscription` forces the WALLET to pay. @openzoobot needs it: the
|
|
431
|
+
// operator's stored subscription key is on the same machine as the poller,
|
|
432
|
+
// so without this every asker's paid question would be settled on OUR key
|
|
433
|
+
// while the reply claimed x402 — and an empty burner would answer happily,
|
|
434
|
+
// which is precisely how this was caught.
|
|
435
|
+
const sub = this.noSubscription ? null : loadSubscription();
|
|
407
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 || {}) };
|
|
408
439
|
const first = await fetchHeaders(url, init);
|
|
409
440
|
if (first.status !== 402) {
|
|
410
441
|
return { response: first, paid: false, subscription: Boolean(sub?.key && first.ok) };
|
package/lib/wrap.js
CHANGED
|
@@ -275,7 +275,13 @@ export function buildWrapInstructions({ pool, owner, depositRaw, rentPayer = own
|
|
|
275
275
|
*/
|
|
276
276
|
export async function confirmSignatureByPolling(connection, signature, {
|
|
277
277
|
commitment = 'confirmed',
|
|
278
|
-
|
|
278
|
+
// 90s was too tight under load: OBSERVED "timed out after 90000ms waiting
|
|
279
|
+
// for confirmation" on a wrap that was still in flight. The cost of that
|
|
280
|
+
// timeout is not a slow reply — it is the asker having PAID and being told
|
|
281
|
+
// the call failed, because the transaction goes on confirming after we stop
|
|
282
|
+
// watching. Waiting longer is strictly cheaper than losing someone's money,
|
|
283
|
+
// so the default is generous and tunable.
|
|
284
|
+
timeoutMs = Number(process.env.OPENZOO_CONFIRM_TIMEOUT_MS || 900000),
|
|
279
285
|
pollMs = 1500,
|
|
280
286
|
} = {}) {
|
|
281
287
|
const accept = commitment === 'finalized' ? ['finalized'] : ['confirmed', 'finalized'];
|
|
@@ -291,7 +297,15 @@ export async function confirmSignatureByPolling(connection, signature, {
|
|
|
291
297
|
if (accept.includes(status.confirmationStatus)) return signature;
|
|
292
298
|
}
|
|
293
299
|
if (Date.now() >= deadline) {
|
|
294
|
-
|
|
300
|
+
// Say what actually happened. "Timed out" reads as "it did not go
|
|
301
|
+
// through", and the opposite is more likely: the signature is real and
|
|
302
|
+
// usually lands. Anyone reading this needs to check before re-sending,
|
|
303
|
+
// or they will pay twice.
|
|
304
|
+
throw new Error(
|
|
305
|
+
`timed out after ${timeoutMs}ms waiting for confirmation of ${signature} `
|
|
306
|
+
+ '— the transaction may STILL confirm; check it before retrying: '
|
|
307
|
+
+ `https://solscan.io/tx/${signature}`,
|
|
308
|
+
);
|
|
295
309
|
}
|
|
296
310
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
297
311
|
}
|