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 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
- const cli = resolveClaudeCli(process.env, rejected);
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
- if (rejected.length) {
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: head -1 ${cli} and file ${cli}`);
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
- const w = loadOrCreateWallet();
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.
@@ -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
- constructor() {
156
- const w = loadOrCreateWallet();
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 deposit = depositForShares(short, reserves, supply);
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 < deposit) {
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: deposit, rentPayer }),
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
- const sub = loadSubscription();
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
- timeoutMs = 90000,
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
- throw new Error(`timed out after ${timeoutMs}ms waiting for confirmation of ${signature}`);
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
  }