openzoo 0.49.7 → 0.49.9
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 +39 -1
- package/bin/openzoo.js +3 -2
- package/lib/claudecode.js +847 -0
- package/lib/grokui.mjs +1351 -226
- package/lib/launch.js +244 -152
- package/lib/livestatus.js +6 -4
- package/lib/modelroute/README.md +1 -0
- package/lib/modelroute/catalog.json +1 -0
- package/lib/modelroute/outcomes.json +1566 -0
- package/lib/modelroute/router.json +1 -0
- package/lib/modelroute.js +737 -0
- package/lib/models.js +221 -44
- package/lib/package.json +3 -0
- package/lib/pay.js +67 -3
- package/lib/podagent.mjs +84 -28
- package/lib/proxy.js +144 -56
- package/lib/racesettle.js +127 -0
- package/lib/relay.js +275 -0
- package/lib/runguard.js +31 -0
- package/lib/spill.js +9 -1
- package/lib/think.js +126 -0
- package/package.json +5 -4
- package/vendor/modelroute/CURRENT_STATE.md +132 -0
- package/vendor/modelroute/HANDOFF.md +159 -0
- package/vendor/modelroute/catalog.json +1 -0
- package/vendor/modelroute/holographic_modelroute.py +809 -0
- package/vendor/modelroute/outcomes.json +1566 -0
- package/vendor/modelroute/router.json +1 -0
package/lib/models.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { config } from './config.js';
|
|
2
2
|
import { fetchHeaders } from './fetch.js';
|
|
3
|
+
import {
|
|
4
|
+
AUTO_MODEL_ID, autoHasPricedModels, autoModelListEntry, isAutoModel,
|
|
5
|
+
isPricedTokenPair, isUnservableRouteId,
|
|
6
|
+
} from './modelroute.js';
|
|
7
|
+
|
|
8
|
+
export { AUTO_MODEL_ID, isAutoModel, isPricedTokenPair, isUnservableRouteId };
|
|
3
9
|
|
|
4
10
|
/** Same threshold as BIND_MIN_CHARS in hrr.js — kept local so this
|
|
5
11
|
* module stays importable without the wallet/rpc stack. */
|
|
@@ -50,18 +56,43 @@ export function editorSlot(id) {
|
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
const CATALOG_TTL_MS = 5 * 60 * 1000;
|
|
53
|
-
let cache = { at: 0, ids: null };
|
|
59
|
+
let cache = { at: 0, ids: null, base: null };
|
|
60
|
+
|
|
61
|
+
export function resetZooModelIdsCache() {
|
|
62
|
+
cache = { at: 0, ids: null, base: null };
|
|
63
|
+
}
|
|
54
64
|
|
|
55
65
|
export async function zooModelIds() {
|
|
56
|
-
if (cache.ids && Date.now() - cache.at < CATALOG_TTL_MS) return cache.ids;
|
|
66
|
+
if (cache.ids && cache.base === config.apiBase && Date.now() - cache.at < CATALOG_TTL_MS) return cache.ids;
|
|
57
67
|
const r = await fetchHeaders(`${config.apiBase}/v1/models`);
|
|
58
68
|
if (!r.ok) throw new Error(`model catalog fetch failed: HTTP ${r.status}`);
|
|
59
69
|
const d = await r.json();
|
|
60
|
-
const ids = (d.data
|
|
61
|
-
if (ids.length) cache = { at: Date.now(), ids };
|
|
70
|
+
const ids = quoteableRows(d.data).map((m) => m.id).filter((id) => id && !isAutoModel(id));
|
|
71
|
+
if (ids.length) cache = { at: Date.now(), ids, base: config.apiBase };
|
|
62
72
|
return ids;
|
|
63
73
|
}
|
|
64
74
|
|
|
75
|
+
/** OpenRouter / gateway token price pair. Image/video rows have no prompt. */
|
|
76
|
+
export function tokenPricePair(pricing) {
|
|
77
|
+
if (!pricing || typeof pricing !== 'object') return [NaN, NaN];
|
|
78
|
+
return [Number(pricing.prompt ?? pricing.input), Number(pricing.completion ?? pricing.output)];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A row the gateway can actually quote for chat. Drops :batch, ~latest
|
|
83
|
+
* pointers, openzoo-* twins, $0 / missing / non-token OpenRouter prices.
|
|
84
|
+
*/
|
|
85
|
+
export function isQuoteableModel(m) {
|
|
86
|
+
const id = m?.id;
|
|
87
|
+
if (isAutoModel(id)) return true;
|
|
88
|
+
if (isUnservableRouteId(id)) return false;
|
|
89
|
+
return isPricedTokenPair(...tokenPricePair(m?.pricing));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function quoteableRows(data) {
|
|
93
|
+
return (Array.isArray(data) ? data : []).filter(isQuoteableModel);
|
|
94
|
+
}
|
|
95
|
+
|
|
65
96
|
/** Vendor fingerprints in harness model ids → zoo catalog prefixes. Order
|
|
66
97
|
* matters only for overlapping hints; first match wins. */
|
|
67
98
|
const FAMILIES = [
|
|
@@ -119,6 +150,14 @@ const tokensOf = (id) => id.toLowerCase().split(/[^a-z0-9.]+/).filter((t) => t &
|
|
|
119
150
|
* OPENZOO_DEFAULT_MODEL is an explicit user override, not a fallback tier.
|
|
120
151
|
*/
|
|
121
152
|
export function resolveModel(requested, ids) {
|
|
153
|
+
// Virtual router id — never family-match, never steal via OPENZOO_DEFAULT_MODEL.
|
|
154
|
+
if (isAutoModel(requested)) return null;
|
|
155
|
+
// Bare Anthropic / Claude Code ids are never live on Fly/OpenRouter
|
|
156
|
+
// (`claude-opus-5` → 500 unknown model). Rewrite even on a catalog miss
|
|
157
|
+
// or if a gateway row lists the bare name — the request must not leave
|
|
158
|
+
// the sidecar as that id.
|
|
159
|
+
const native = anthropicNativeAlias(requested);
|
|
160
|
+
if (native) return native;
|
|
122
161
|
if (!requested || !ids?.length || ids.includes(requested)) return null;
|
|
123
162
|
// `openzoo-` prefixed names exist so an editor cannot mistake them for its
|
|
124
163
|
// OWN models: Cursor claims any name in its catalog (claude-opus-5, grok-4.6)
|
|
@@ -187,6 +226,46 @@ export function resolveModel(requested, ids) {
|
|
|
187
226
|
return best;
|
|
188
227
|
}
|
|
189
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Bare Anthropic / Claude Code ids. MEASURED 2026-08-20 against Fly
|
|
231
|
+
* x402-tokens: POST /v1/chat/completions model=claude-opus-5 → 500
|
|
232
|
+
* `unknown model claude-opus-5`. The vendor-prefixed twin
|
|
233
|
+
* (`anthropic/claude-opus-5`) 402s and is priced. Claude Code's Auto
|
|
234
|
+
* permission classifier calls these ids on ANTHROPIC_BASE_URL; if the
|
|
235
|
+
* sidecar forwards the bare name, Bash hangs with
|
|
236
|
+
* "claude-opus-5 is temporarily unavailable, so auto mode cannot
|
|
237
|
+
* determine the safety of Bash". Always rewrite. Never send the bare
|
|
238
|
+
* id to OpenRouter / Fly. Do not push x402-tokens — alias here.
|
|
239
|
+
*/
|
|
240
|
+
export const ANTHROPIC_NATIVE_ALIASES = {
|
|
241
|
+
'claude-opus-5': 'anthropic/claude-opus-5',
|
|
242
|
+
'claude-opus-5-fast': 'anthropic/claude-opus-5-fast',
|
|
243
|
+
'claude-3-5-opus': 'anthropic/claude-opus-5',
|
|
244
|
+
'claude-sonnet-5': 'anthropic/claude-sonnet-5',
|
|
245
|
+
'claude-sonnet-5-fast': 'anthropic/claude-sonnet-5',
|
|
246
|
+
'claude-opus-4-8': 'anthropic/claude-opus-4.8',
|
|
247
|
+
'claude-opus-4.8': 'anthropic/claude-opus-4.8',
|
|
248
|
+
'claude-fable-5': 'anthropic/claude-fable-5',
|
|
249
|
+
'claude-haiku-4.5': 'anthropic/claude-haiku-4.5',
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Claude Code sometimes suffixes a window marker (`claude-opus-5[1m]`). */
|
|
253
|
+
function stripAnthropicWindowSuffix(id) {
|
|
254
|
+
return String(id || '').trim().replace(/\[[\d]+m\]$/i, '');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Priced zoo twin for a bare Anthropic / Claude Code id, or null.
|
|
259
|
+
* Vendor-prefixed ids and openzoo-* twins are left to the rest of resolveModel.
|
|
260
|
+
*/
|
|
261
|
+
export function anthropicNativeAlias(requested) {
|
|
262
|
+
if (!requested || typeof requested !== 'string') return null;
|
|
263
|
+
const stripped = stripAnthropicWindowSuffix(requested);
|
|
264
|
+
if (!stripped || stripped.includes('/')) return null;
|
|
265
|
+
if (/^openzoo[-/]/i.test(stripped)) return null;
|
|
266
|
+
return ANTHROPIC_NATIVE_ALIASES[stripped] || ANTHROPIC_NATIVE_ALIASES[stripped.toLowerCase()] || null;
|
|
267
|
+
}
|
|
268
|
+
|
|
190
269
|
/**
|
|
191
270
|
* Ids harnesses ship as DEFAULTS (Cursor, Continue, Aider, Codex CLI, Cline,
|
|
192
271
|
* OpenClaw, LangChain templates…). Merged into GET /v1/models so a harness
|
|
@@ -197,58 +276,153 @@ export const ALIAS_IDS = [
|
|
|
197
276
|
'gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4-turbo', 'gpt-3.5-turbo',
|
|
198
277
|
'gpt-5', 'gpt-5-mini', 'chatgpt-4o-latest', 'o1', 'o3', 'o3-mini', 'o4-mini',
|
|
199
278
|
'claude-3-5-sonnet-latest', 'claude-sonnet-4-0', 'claude-opus-4-1',
|
|
279
|
+
'claude-opus-5', 'claude-opus-5-fast', 'claude-3-5-opus', 'claude-sonnet-5',
|
|
280
|
+
'claude-opus-4-8', 'claude-fable-5',
|
|
200
281
|
'gemini-2.5-pro', 'gemini-2.5-flash', 'grok-4', 'grok-3',
|
|
201
282
|
'deepseek-chat', 'deepseek-reasoner', 'qwen-max', 'llama-3.3-70b',
|
|
202
283
|
];
|
|
203
284
|
|
|
285
|
+
export function isHarnessAliasId(id) {
|
|
286
|
+
const stripped = stripAnthropicWindowSuffix(id);
|
|
287
|
+
return ALIAS_IDS.includes(stripped) || Boolean(anthropicNativeAlias(stripped));
|
|
288
|
+
}
|
|
289
|
+
|
|
204
290
|
/**
|
|
205
291
|
* Merge alias rows into a /v1/models payload without duplicating real ids.
|
|
206
292
|
* Each alias inherits context_length and pricing from the model it RESOLVES
|
|
207
293
|
* to — a harness sizing its corpus off "gpt-4o" gets the real ceiling of the
|
|
208
294
|
* model that will actually serve it, not a blank.
|
|
295
|
+
*
|
|
296
|
+
* Does not mint openzoo-* twins. Those duplicated every real id (and every
|
|
297
|
+
* :batch id) in Claude Code's /model picker.
|
|
209
298
|
*/
|
|
210
|
-
export function augmentModelList(payload) {
|
|
211
|
-
const data =
|
|
299
|
+
export function augmentModelList(payload, { aliases: withAliases = true } = {}) {
|
|
300
|
+
const data = quoteableRows(payload?.data);
|
|
212
301
|
const have = new Set(data.map((m) => m.id));
|
|
213
302
|
const ids = data.map((m) => m.id);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
owned_by: 'openzoo',
|
|
234
|
-
served_by: id,
|
|
235
|
-
...(src?.context_length ? { context_length: src.context_length, context_window: src.context_window ?? src.context_length } : {}),
|
|
236
|
-
...(src?.pricing ? { pricing: src.pricing } : {}),
|
|
237
|
-
});
|
|
238
|
-
have.add(name);
|
|
303
|
+
const aliases = withAliases
|
|
304
|
+
? ALIAS_IDS.filter((id) => !have.has(id)).map((id) => {
|
|
305
|
+
const target = data.find((m) => m.id === resolveModel(id, ids));
|
|
306
|
+
return {
|
|
307
|
+
id,
|
|
308
|
+
object: 'model',
|
|
309
|
+
owned_by: 'openzoo-alias',
|
|
310
|
+
...(target?.context_length ? { context_length: target.context_length, context_window: target.context_window ?? target.context_length } : {}),
|
|
311
|
+
...(target?.pricing ? { pricing: target.pricing } : {}),
|
|
312
|
+
...(target ? { served_by: target.id } : {}),
|
|
313
|
+
};
|
|
314
|
+
})
|
|
315
|
+
: [];
|
|
316
|
+
const virtual = [];
|
|
317
|
+
// Auto is listed only when its own shortlist is priced models — never an
|
|
318
|
+
// unquoted OpenRouter id that 500s `bad openrouter price`.
|
|
319
|
+
if (!have.has(AUTO_MODEL_ID) && autoHasPricedModels(undefined, ids.length ? ids : null)) {
|
|
320
|
+
virtual.push(autoModelListEntry());
|
|
321
|
+
have.add(AUTO_MODEL_ID);
|
|
239
322
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
323
|
+
return { ...payload, object: payload?.object || 'list', data: [...virtual, ...data, ...aliases] };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Label a catalog id for a picker without minting a new id.
|
|
328
|
+
* `x-ai/grok-4.6` stays `x-ai/grok-4.6` on the wire; the label is just
|
|
329
|
+
* "grok-4.6 (x-ai)". Never invents a `claude-*` name for a non-Anthropic animal.
|
|
330
|
+
*/
|
|
331
|
+
export function displayNameFor(id) {
|
|
332
|
+
const raw = String(id || '').trim();
|
|
333
|
+
if (!raw) return raw;
|
|
334
|
+
if (raw.includes('/')) {
|
|
335
|
+
const slash = raw.indexOf('/');
|
|
336
|
+
return `${raw.slice(slash + 1)} (${raw.slice(0, slash)})`;
|
|
337
|
+
}
|
|
338
|
+
return raw;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function decorateModelEntry(m) {
|
|
342
|
+
if (!m || typeof m !== 'object') return m;
|
|
343
|
+
return {
|
|
344
|
+
...m,
|
|
345
|
+
object: m.object || 'model',
|
|
346
|
+
type: m.type || 'model',
|
|
347
|
+
display_name: m.display_name || displayNameFor(m.id),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* OpenAI-compatible /v1/models body. Quoteable chat models only — no :batch,
|
|
353
|
+
* no unpriced / image-video rows, no openzoo-* twins. Extra Anthropic fields
|
|
354
|
+
* (`type`, `display_name`) sit alongside OpenAI ones.
|
|
355
|
+
*/
|
|
356
|
+
export function publishModelList(payload, opts) {
|
|
357
|
+
const merged = augmentModelList(payload, opts);
|
|
358
|
+
const data = (merged.data || []).map(decorateModelEntry);
|
|
359
|
+
return {
|
|
360
|
+
...merged,
|
|
361
|
+
object: merged.object || 'list',
|
|
362
|
+
data,
|
|
363
|
+
has_more: false,
|
|
364
|
+
first_id: data[0]?.id ?? null,
|
|
365
|
+
last_id: data[data.length - 1]?.id ?? null,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function anthropicRows(rows) {
|
|
370
|
+
return rows.map((m) => ({
|
|
371
|
+
type: 'model',
|
|
372
|
+
id: m.id,
|
|
373
|
+
display_name: m.display_name || displayNameFor(m.id),
|
|
374
|
+
...(m.created_at ? { created_at: m.created_at } : {}),
|
|
375
|
+
...(m.served_by ? { served_by: m.served_by } : {}),
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** openzoo/auto first so Claude Code's picker default is the router, not opus. */
|
|
380
|
+
function withAutoFirst(rows) {
|
|
381
|
+
const list = Array.isArray(rows) ? rows.slice() : [];
|
|
382
|
+
const i = list.findIndex((m) => isAutoModel(m?.id));
|
|
383
|
+
if (i > 0) {
|
|
384
|
+
const [auto] = list.splice(i, 1);
|
|
385
|
+
list.unshift(auto);
|
|
386
|
+
}
|
|
387
|
+
return list;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Anthropic GET /v1/models shape (id + display_name + type).
|
|
392
|
+
* Claude Code gateway discovery reads `data[].id` and optional `display_name`.
|
|
393
|
+
* Full quoteable catalog — every priced published id, including OpenRouter
|
|
394
|
+
* grok/gemini/gpt rows. Not a 4-id Anthropic cap, not openzoo-* clones.
|
|
395
|
+
* Picker ≠ classifier: rewriteChatModel still pins 16-token classify off opus-5.
|
|
396
|
+
*/
|
|
397
|
+
export function anthropicModelList(payload) {
|
|
398
|
+
const published = publishModelList(payload, { aliases: false });
|
|
399
|
+
const data = anthropicRows(withAutoFirst(published.data || []));
|
|
400
|
+
return {
|
|
401
|
+
data,
|
|
402
|
+
has_more: false,
|
|
403
|
+
first_id: data[0]?.id ?? null,
|
|
404
|
+
last_id: data[data.length - 1]?.id ?? null,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Claude Code / Anthropic SDKs send anthropic-version or an x-app / UA hint. */
|
|
409
|
+
export function wantsAnthropicModelList(headers = {}) {
|
|
410
|
+
const h = headers && typeof headers === 'object' ? headers : {};
|
|
411
|
+
const get = (k) => h[k] ?? h[k.toLowerCase()] ?? '';
|
|
412
|
+
return Boolean(get('anthropic-version'))
|
|
413
|
+
|| /claude|anthropic/i.test(String(get('x-app')))
|
|
414
|
+
|| /claude|anthropic/i.test(String(get('user-agent')));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Body for GET /v1/models. Quoteable catalog only (never opus-5-only, never
|
|
419
|
+
* unpriced / :batch / openzoo-* clones). Anthropic-shaped clients get the
|
|
420
|
+
* same quoteable ids in Anthropic shape (type / id / display_name) so Claude
|
|
421
|
+
* Code can select grok, gemini, gpt, etc. OpenAI clients keep object:list
|
|
422
|
+
* of every quoteable id + harness aliases.
|
|
423
|
+
*/
|
|
424
|
+
export function modelsListForRequest(payload, headers) {
|
|
425
|
+
return wantsAnthropicModelList(headers) ? anthropicModelList(payload) : publishModelList(payload);
|
|
252
426
|
}
|
|
253
427
|
|
|
254
428
|
/**
|
|
@@ -385,6 +559,9 @@ export function raiseReasoningMaxTokens(parsed, env = process.env) {
|
|
|
385
559
|
export function rewriteChatModel(parsed, ids, { bodyLen } = {}) {
|
|
386
560
|
const from = parsed?.model;
|
|
387
561
|
const len = bodyLen ?? (parsed == null ? 0 : Buffer.byteLength(JSON.stringify(parsed)));
|
|
562
|
+
if (isAutoModel(from) && !isTinyClassify(parsed, len)) {
|
|
563
|
+
return { parsed, tiny: false, auto: true, from, to: AUTO_MODEL_ID, raised: false };
|
|
564
|
+
}
|
|
388
565
|
if (isTinyClassify(parsed, len)) {
|
|
389
566
|
const picked = pickClassifierModel(ids);
|
|
390
567
|
// pickClassifierModel returns null on an empty catalog or a zoo that
|
|
@@ -403,7 +580,7 @@ export function rewriteChatModel(parsed, ids, { bodyLen } = {}) {
|
|
|
403
580
|
if (typeof from !== 'string') {
|
|
404
581
|
return { parsed, tiny: false, from, to: from, raised: false };
|
|
405
582
|
}
|
|
406
|
-
const resolved = resolveModel(from, ids);
|
|
583
|
+
const resolved = resolveModel(from, ids) || anthropicNativeAlias(from);
|
|
407
584
|
const next = resolved ? { ...parsed, model: resolved } : parsed;
|
|
408
585
|
const bump = raiseReasoningMaxTokens(next);
|
|
409
586
|
return {
|
package/lib/package.json
ADDED
package/lib/pay.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Connection, PublicKey } from '@solana/web3.js';
|
|
2
2
|
import { getAssociatedTokenAddressSync } from '@solana/spl-token';
|
|
3
|
-
import { config, fundingLine, evmRpcFor } from './config.js';
|
|
3
|
+
import { config, fundingLine, evmRpcFor, FUNDING_ASSETS } from './config.js';
|
|
4
4
|
import { loadOrCreateWallet } from './wallet.js';
|
|
5
5
|
import {
|
|
6
6
|
parse402, orderAccepts, railOf, buildPaymentOnline, tokenBalance,
|
|
@@ -120,6 +120,37 @@ export function resetRailMemory() {
|
|
|
120
120
|
lastGoodAsset = null;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Skip wrap / pool / sendWrap when there is nothing to convert.
|
|
125
|
+
* wrapped < need AND underlying == 0 → UnderfundedError, no RPC walk.
|
|
126
|
+
* A funded wallet (underlying > 0) still wraps.
|
|
127
|
+
*/
|
|
128
|
+
export function skipWrapWhenEmpty(wrappedRaw, underlyingRaw, need) {
|
|
129
|
+
const wrapped = wrappedRaw == null ? 0n : BigInt(wrappedRaw);
|
|
130
|
+
const underlying = underlyingRaw == null ? 0n : BigInt(underlyingRaw);
|
|
131
|
+
const want = need == null ? 0n : BigInt(need);
|
|
132
|
+
return wrapped < want && underlying <= 0n;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cheap underlying mint for a 402 row — directory acquire address, or the
|
|
137
|
+
* known USDC / TOKEN / LEOS twin named in extra.symbol. No chain probe.
|
|
138
|
+
*/
|
|
139
|
+
export function hintedUnderlyingMint(accept) {
|
|
140
|
+
const acq = accept?.extra?.acquire?.underlying;
|
|
141
|
+
const addr = typeof acq === 'string' ? acq : acq?.address;
|
|
142
|
+
if (addr && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr)) return addr;
|
|
143
|
+
const plain = String(accept?.extra?.symbol || '').replace(/^[wy]/i, '').replace(/x$/i, '').toUpperCase();
|
|
144
|
+
return FUNDING_ASSETS.find((a) => a.symbol === plain)?.mint || null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True when every Solana funding mint (USDC / TOKEN / LEOS) reads 0. */
|
|
148
|
+
export function solanaFundingEmpty(balances) {
|
|
149
|
+
const rows = Array.isArray(balances) ? balances : [];
|
|
150
|
+
if (!rows.length) return false;
|
|
151
|
+
return rows.every((b) => (b?.raw == null ? 0n : BigInt(b.raw)) <= 0n);
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
export class PayClient {
|
|
124
155
|
constructor() {
|
|
125
156
|
const w = loadOrCreateWallet();
|
|
@@ -213,6 +244,11 @@ export class PayClient {
|
|
|
213
244
|
}
|
|
214
245
|
}
|
|
215
246
|
|
|
247
|
+
async solanaFundingBalances() {
|
|
248
|
+
const owner = this.keypair.publicKey;
|
|
249
|
+
return Promise.all(FUNDING_ASSETS.map((a) => cachedTokenBalance(this.connection, owner, a.mint)));
|
|
250
|
+
}
|
|
251
|
+
|
|
216
252
|
async buildPaymentFor(accept, onStage) {
|
|
217
253
|
const rail = railOf(accept);
|
|
218
254
|
if (rail === 'solana') {
|
|
@@ -224,6 +260,22 @@ export class PayClient {
|
|
|
224
260
|
bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true });
|
|
225
261
|
}
|
|
226
262
|
if (bal.raw < need) {
|
|
263
|
+
// Empty first-run burner: 0 wrapped + 0 USDC/TOKEN/LEOS. Do not
|
|
264
|
+
// resolvePool / poolState / sendWrap just to learn that.
|
|
265
|
+
const funding = await this.solanaFundingBalances();
|
|
266
|
+
if (solanaFundingEmpty(funding)) {
|
|
267
|
+
throw new UnderfundedError(accept, 0, this.address);
|
|
268
|
+
}
|
|
269
|
+
const underMint = hintedUnderlyingMint(accept);
|
|
270
|
+
if (underMint) {
|
|
271
|
+
let under = await cachedTokenBalance(this.connection, this.keypair.publicKey, underMint);
|
|
272
|
+
if (under.raw <= 0n && under.cached) {
|
|
273
|
+
under = await cachedTokenBalance(this.connection, this.keypair.publicKey, underMint, { force: true });
|
|
274
|
+
}
|
|
275
|
+
if (skipWrapWhenEmpty(bal.raw, under.raw, need)) {
|
|
276
|
+
throw new UnderfundedError(accept, under.ui, this.address);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
227
279
|
const topUp = await this.topUpQuotedAsset(accept, need, onStage);
|
|
228
280
|
if (topUp.preInstructions) {
|
|
229
281
|
return buildPaymentOnline(this.connection, this.keypair, accept, {
|
|
@@ -290,14 +342,26 @@ export class PayClient {
|
|
|
290
342
|
const pool = await resolvePool(this.connection, accept.asset).catch(() => null);
|
|
291
343
|
if (!pool) throw new UnderfundedError(accept, null, this.address);
|
|
292
344
|
|
|
345
|
+
// Balances first. 0 wrapped + 0 underlying → throw. Never poolState or
|
|
346
|
+
// sendWrap just to discover an empty burner (measured ~4.5s per row).
|
|
347
|
+
const [wrappedNow, underlyingNow] = await Promise.all([
|
|
348
|
+
tokenBalance(this.connection, owner, accept.asset),
|
|
349
|
+
tokenBalance(this.connection, owner, pool.underlying.toBase58()),
|
|
350
|
+
]);
|
|
351
|
+
if (skipWrapWhenEmpty(wrappedNow.raw, underlyingNow.raw, need)) {
|
|
352
|
+
throw new UnderfundedError(accept, underlyingNow.ui, this.address);
|
|
353
|
+
}
|
|
354
|
+
|
|
293
355
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
294
|
-
const bal = await tokenBalance(this.connection, owner, accept.asset);
|
|
356
|
+
const bal = attempt === 0 ? wrappedNow : await tokenBalance(this.connection, owner, accept.asset);
|
|
295
357
|
const short = need - bal.raw;
|
|
296
358
|
if (short <= 0n) return {};
|
|
297
359
|
|
|
298
360
|
const { reserves, supply } = await poolState(this.connection, pool);
|
|
299
361
|
const deposit = depositForShares(short, reserves, supply);
|
|
300
|
-
const underlyingBal =
|
|
362
|
+
const underlyingBal = attempt === 0
|
|
363
|
+
? underlyingNow
|
|
364
|
+
: await tokenBalance(this.connection, owner, pool.underlying.toBase58());
|
|
301
365
|
if (underlyingBal.raw < deposit) {
|
|
302
366
|
throw new UnderfundedError(accept, underlyingBal.ui, this.address);
|
|
303
367
|
}
|