openzoo 0.49.2 → 0.49.4
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 +1 -1
- package/lib/grokui.mjs +67 -23
- package/lib/pay.js +2 -1
- package/lib/podagent.mjs +16 -2
- package/lib/proxy.js +3 -1
- package/lib/racesettle.js +13 -27
- package/lib/wrap.js +88 -29
- package/lib/x402.js +11 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -187,7 +187,7 @@ All three rails have settled real payments (2026-08-14):
|
|
|
187
187
|
|
|
188
188
|
| rail | network | status |
|
|
189
189
|
|---|---|---|
|
|
190
|
-
| **Solana** (default) | `solana:5eykt…` | **live** — Token-2022 `TransferChecked`, partial-signed, gateway pays fees. Settles daily; tested end-to-end against the production 402. Settlement uses a wrapped
|
|
190
|
+
| **Solana** (default) | `solana:5eykt…` | **live** — Token-2022 payment `TransferChecked`, partial-signed, gateway pays fees. Settles daily; tested end-to-end against the production 402. Settlement uses a wrapped mint as internal plumbing; you only ever hold and send USDC or TOKEN. Funding wrap is a 9-account ix (program pulls the deposit; old 5-account wrap is rejected `0x6a`). |
|
|
191
191
|
| Base | `eip155:8453` | **live** — standard x402 EIP-3009 `transferWithAuthorization` against native USDC, batched settle through the facilitator. Fund the wallet's EVM address with USDC on Base; nothing is converted. |
|
|
192
192
|
| Robinhood Chain | `eip155:4663` | **live** — EIP-3009, batched settle through the facilitator. Hold the plain token a row is quoted in (USDG, or the ODDBALLER / IOU / ROBINHOODS memecoins) and the shim converts exactly enough at payment time, automatically — two small on-chain steps paid from the wallet's own RH ETH. No gas? The error says exactly how much ETH to send and where. Default rail selection skips this chain unless `OPENZOO_ENABLE_RH=1`; `OPENZOO_RAIL=robinhood` forces it outright. |
|
|
193
193
|
|
package/lib/grokui.mjs
CHANGED
|
@@ -691,6 +691,7 @@ const AUTO_RACE_RETRY = 'AUTO is still on — the last model call failed (race/e
|
|
|
691
691
|
+ 'Do not stop and do not ask the user to type continue. '
|
|
692
692
|
+ 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
|
|
693
693
|
+ 'or DONE: if the job is actually finished.';
|
|
694
|
+
const AUTO_EMPTY_RETRY = 'AUTO_EMPTY_RETRY: the command produced no output, try a different command or a different path, do not stop.';
|
|
694
695
|
// Said it would, without a directive line. "Spawned X" and "working on it" are
|
|
695
696
|
// in here because they are FALSE without a SPAWN: in the same reply — the bot
|
|
696
697
|
// reports success for something the harness never saw.
|
|
@@ -709,13 +710,43 @@ function isTransientModelFail(text) {
|
|
|
709
710
|
function isPaymentFailed(text) {
|
|
710
711
|
return /\b(?:payment failed|HTTP 402|wallet is empty|empty wallet)\b/i.test(String(text || ''));
|
|
711
712
|
}
|
|
713
|
+
// Empty stdout, "(no output)", or a directive that found nothing. That is
|
|
714
|
+
// still a command-output hop today, so AUTO used to chain once and then park
|
|
715
|
+
// as if the job had succeeded. It has not — try another command or path.
|
|
716
|
+
function isEmptyExecOutput(text) {
|
|
717
|
+
const s = String(text ?? '').trim();
|
|
718
|
+
return !s || s === '(no output)';
|
|
719
|
+
}
|
|
720
|
+
function isEmptyDirectiveAck(text) {
|
|
721
|
+
const s = String(text ?? '').trim();
|
|
722
|
+
if (isEmptyExecOutput(s)) return true;
|
|
723
|
+
if (/:\s*\(empty\)$/i.test(s)) return true;
|
|
724
|
+
if (/:\s*no matches\s*$/i.test(s)) return true;
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
function isEmptyToolResult(text) {
|
|
728
|
+
if (text == null) return false;
|
|
729
|
+
const s = String(text).trim();
|
|
730
|
+
if (isEmptyExecOutput(s)) return true;
|
|
731
|
+
const cmd = /^\(command output\)\s*([\s\S]*)$/.exec(s);
|
|
732
|
+
if (cmd) return isEmptyExecOutput(cmd[1]);
|
|
733
|
+
const dir = /^\(directive result\)\s*([\s\S]*)$/.exec(s);
|
|
734
|
+
if (dir) return isEmptyDirectiveAck(dir[1]);
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
function isEmptyShownRun(text) {
|
|
738
|
+
const shown = /^\$ [^\n]*\n([\s\S]*)$/.exec(String(text ?? ''));
|
|
739
|
+
return Boolean(shown && isEmptyExecOutput(shown[1]));
|
|
740
|
+
}
|
|
712
741
|
// Park only: ask mode, pendingRun, DONE:, 402/empty-wallet, or the hard cap.
|
|
713
|
-
|
|
742
|
+
// Empty /(no output) exec is not DONE — keep going with AUTO_EMPTY_RETRY.
|
|
743
|
+
function shouldKeepAuto(t, reply, userText) {
|
|
714
744
|
if (!t || t.runMode !== 'auto') return false;
|
|
715
745
|
if (t.pendingRun) return false;
|
|
716
746
|
if ((t.autoSteps || 0) >= AUTO_MAX_STEPS) return false;
|
|
717
|
-
if (isDoneReply(reply)) return false;
|
|
718
747
|
if (isPaymentFailed(reply)) return false;
|
|
748
|
+
if (isEmptyToolResult(userText) || isEmptyShownRun(reply)) return true;
|
|
749
|
+
if (isDoneReply(reply)) return false;
|
|
719
750
|
return true;
|
|
720
751
|
}
|
|
721
752
|
function enqueueAutoHop(t, threadId, userText, onEvent) {
|
|
@@ -725,7 +756,8 @@ function enqueueAutoHop(t, threadId, userText, onEvent) {
|
|
|
725
756
|
kickTurn(threadId, userText, onEvent).catch(() => {});
|
|
726
757
|
return true;
|
|
727
758
|
}
|
|
728
|
-
function autoHopText(reply) {
|
|
759
|
+
function autoHopText(reply, userText) {
|
|
760
|
+
if (isEmptyToolResult(userText) || isEmptyShownRun(reply)) return AUTO_EMPTY_RETRY;
|
|
729
761
|
return isTransientModelFail(reply) ? AUTO_RACE_RETRY : AUTO_CONTINUE;
|
|
730
762
|
}
|
|
731
763
|
// PING used to be a read: last-line status, no turn. Idle children stayed idle
|
|
@@ -805,7 +837,8 @@ without a fact only the user has.
|
|
|
805
837
|
|
|
806
838
|
When the job is actually finished, emit DONE: as the first line. A status
|
|
807
839
|
sentence is not a stop — the harness keeps this thread working until DONE:,
|
|
808
|
-
a real blocking question, or the step cap
|
|
840
|
+
a real blocking question, or the step cap. Empty output, "(no output)", and
|
|
841
|
+
GLOB/GREP with no matches are not finished — try a different command or path.`;
|
|
809
842
|
async function bindThread(t) {
|
|
810
843
|
// Only bind what's NEW since the last successful bind, continuing the
|
|
811
844
|
// existing context_id — previously this rebuilt and re-sent the WHOLE
|
|
@@ -992,7 +1025,7 @@ function execCommand(command, cwd) {
|
|
|
992
1025
|
exec(command, { cwd, shell: RUN_SHELL, timeout: RUN_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
993
1026
|
let out = (stdout || '') + (stderr ? '\n' + stderr : '');
|
|
994
1027
|
if (err) out += `\n(exit ${err.code ?? 1})`;
|
|
995
|
-
resolve(keepWhole(out) || '(no output)');
|
|
1028
|
+
resolve(keepWhole(out).trim() || '(no output)');
|
|
996
1029
|
});
|
|
997
1030
|
});
|
|
998
1031
|
}
|
|
@@ -1411,7 +1444,7 @@ setInterval(() => {
|
|
|
1411
1444
|
const lastBot = [...(t.history || [])].reverse().find((h) => h.who === 'bot');
|
|
1412
1445
|
const lastText = lastBot?.text || '';
|
|
1413
1446
|
if (shouldKeepAuto(t, lastText)) {
|
|
1414
|
-
kickTurn(t.id,
|
|
1447
|
+
kickTurn(t.id, autoHopText(lastText)).catch(() => {});
|
|
1415
1448
|
} else {
|
|
1416
1449
|
t.status = 'idle';
|
|
1417
1450
|
t.liveStatus = '';
|
|
@@ -1676,7 +1709,9 @@ function isHarnessUserText(text) {
|
|
|
1676
1709
|
return /^\((command output|directive result)\)/.test(String(text || ''))
|
|
1677
1710
|
|| String(text || '') === NUDGE
|
|
1678
1711
|
|| String(text || '') === AUTO_CONTINUE
|
|
1679
|
-
|| String(text || '') === AUTO_RACE_RETRY
|
|
1712
|
+
|| String(text || '') === AUTO_RACE_RETRY
|
|
1713
|
+
|| String(text || '') === AUTO_EMPTY_RETRY
|
|
1714
|
+
|| String(text || '').startsWith('AUTO_EMPTY_RETRY:');
|
|
1680
1715
|
}
|
|
1681
1716
|
|
|
1682
1717
|
function firstUserAsk(t) {
|
|
@@ -2502,8 +2537,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2502
2537
|
}
|
|
2503
2538
|
bindThread(t).catch(() => {});
|
|
2504
2539
|
lastReply = memberReply;
|
|
2505
|
-
if (shouldKeepAuto(t, memberReply)) {
|
|
2506
|
-
chained = enqueueAutoHop(t, threadId, autoHopText(memberReply), onEvent);
|
|
2540
|
+
if (shouldKeepAuto(t, memberReply, userText)) {
|
|
2541
|
+
chained = enqueueAutoHop(t, threadId, autoHopText(memberReply, userText), onEvent);
|
|
2507
2542
|
}
|
|
2508
2543
|
return;
|
|
2509
2544
|
}
|
|
@@ -2638,7 +2673,11 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2638
2673
|
// most material (command output, GLOB results, MCP tool lists). The
|
|
2639
2674
|
// holographic context stopped growing precisely when it mattered.
|
|
2640
2675
|
lastReply = shown;
|
|
2641
|
-
chained = enqueueAutoHop(
|
|
2676
|
+
chained = enqueueAutoHop(
|
|
2677
|
+
t, threadId,
|
|
2678
|
+
isEmptyExecOutput(output) ? AUTO_EMPTY_RETRY : condense('(command output)', output),
|
|
2679
|
+
onEvent,
|
|
2680
|
+
);
|
|
2642
2681
|
return;
|
|
2643
2682
|
}
|
|
2644
2683
|
{
|
|
@@ -2668,8 +2707,12 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2668
2707
|
// Same budget as RUN (shared t.autoSteps, reset when the user speaks), so
|
|
2669
2708
|
// this cannot spend more than a chained RUN loop already could.
|
|
2670
2709
|
if (t.runMode === 'auto' && ack !== null && ack !== undefined
|
|
2671
|
-
&& !isDoneReply(reply)) {
|
|
2672
|
-
chained = enqueueAutoHop(
|
|
2710
|
+
&& (isEmptyDirectiveAck(ack) || !isDoneReply(reply))) {
|
|
2711
|
+
chained = enqueueAutoHop(
|
|
2712
|
+
t, threadId,
|
|
2713
|
+
isEmptyDirectiveAck(ack) ? AUTO_EMPTY_RETRY : condense('(directive result)', ack),
|
|
2714
|
+
onEvent,
|
|
2715
|
+
);
|
|
2673
2716
|
return;
|
|
2674
2717
|
}
|
|
2675
2718
|
|
|
@@ -2699,17 +2742,18 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2699
2742
|
|
|
2700
2743
|
// After any auto reply that is not DONE: and not waiting on approval,
|
|
2701
2744
|
// kick immediately. Race/empty/error uses AUTO_RACE_RETRY.
|
|
2702
|
-
if (shouldKeepAuto(t, reply)) {
|
|
2703
|
-
chained = enqueueAutoHop(t, threadId, autoHopText(reply), onEvent);
|
|
2745
|
+
if (shouldKeepAuto(t, reply, userText)) {
|
|
2746
|
+
chained = enqueueAutoHop(t, threadId, autoHopText(reply, userText), onEvent);
|
|
2704
2747
|
return;
|
|
2705
2748
|
}
|
|
2706
2749
|
bindThread(t).catch(() => {});
|
|
2707
2750
|
} finally {
|
|
2708
2751
|
// Idle only when this hop should not keep AUTO going: DONE:, pendingRun,
|
|
2709
|
-
// ask mode, 402/empty-wallet, or the hard cap.
|
|
2752
|
+
// ask mode, 402/empty-wallet, or the hard cap. Empty /(no output) is not
|
|
2753
|
+
// DONE — AUTO_EMPTY_RETRY. Otherwise kick again.
|
|
2710
2754
|
if (stillMine() && !chained && !parked) {
|
|
2711
|
-
if (shouldKeepAuto(t, lastReply)) {
|
|
2712
|
-
enqueueAutoHop(t, threadId, autoHopText(lastReply), onEvent);
|
|
2755
|
+
if (shouldKeepAuto(t, lastReply, userText)) {
|
|
2756
|
+
enqueueAutoHop(t, threadId, autoHopText(lastReply, userText), onEvent);
|
|
2713
2757
|
} else if (!t.pendingRun) {
|
|
2714
2758
|
t.status = 'idle';
|
|
2715
2759
|
t.liveStatus = '';
|
|
@@ -4973,12 +5017,12 @@ const APP_HTML = `<!doctype html>
|
|
|
4973
5017
|
// is shipping the WHOLE corpus), so 2dp would read as noise up there.
|
|
4974
5018
|
savedEl.textContent = (mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x';
|
|
4975
5019
|
savedEl.className = mult >= 1 ? 'hlime' : 'hember';
|
|
4976
|
-
// Session direct/spent (never first-call).
|
|
4977
|
-
//
|
|
5020
|
+
// Session direct/spent (never first-call). Ember when cogs > spent —
|
|
5021
|
+
// house losing. Do not treat race_unused as a user refund.
|
|
4978
5022
|
if (cogsOver) {
|
|
4979
5023
|
hintEl.className = 'hhint show';
|
|
4980
|
-
hintEl.innerHTML = '<b>cogs above paid.</b> our cost exceeded what you were billed '
|
|
4981
|
-
+ '
|
|
5024
|
+
hintEl.innerHTML = '<b>cogs above paid.</b> house is losing — our cost exceeded what you were billed. '
|
|
5025
|
+
+ 'you pay for every entrant we actually launched; failures still cost us.';
|
|
4982
5026
|
} else {
|
|
4983
5027
|
hintEl.className = mult >= 1 ? 'hhint' : 'hhint show';
|
|
4984
5028
|
hintEl.innerHTML = '<b>feed it more.</b> you\\'re billed on the slice actually sent, '
|
|
@@ -5423,8 +5467,8 @@ export {
|
|
|
5423
5467
|
tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
|
|
5424
5468
|
parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
|
|
5425
5469
|
handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, runTurn,
|
|
5426
|
-
AUTO_CONTINUE, AUTO_RACE_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
|
|
5427
|
-
isDoneReply, isTransientModelFail, enqueueAutoHop, childKickoff, findByName,
|
|
5470
|
+
AUTO_CONTINUE, AUTO_RACE_RETRY, AUTO_EMPTY_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
|
|
5471
|
+
isDoneReply, isTransientModelFail, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
|
|
5428
5472
|
attachChildDir, finishChildDir,
|
|
5429
5473
|
lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
|
|
5430
5474
|
};
|
package/lib/pay.js
CHANGED
|
@@ -12,6 +12,7 @@ import { privateKeyToAccount } from 'viem/accounts';
|
|
|
12
12
|
import { withNamespace } from './namespace.js';
|
|
13
13
|
import {
|
|
14
14
|
resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
|
|
15
|
+
rewriteWrapClientError,
|
|
15
16
|
} from './wrap.js';
|
|
16
17
|
import { applySubscriptionHeaders, loadSubscription, stripAuthorization } from './subscription.js';
|
|
17
18
|
import { fetchHeaders } from './fetch.js';
|
|
@@ -434,7 +435,7 @@ export class PayClient {
|
|
|
434
435
|
body: JSON.stringify(bodyObj),
|
|
435
436
|
}, { onStage });
|
|
436
437
|
if (!response.ok) {
|
|
437
|
-
const text = (await response.text()).slice(0, 500);
|
|
438
|
+
const text = rewriteWrapClientError((await response.text()).slice(0, 500));
|
|
438
439
|
throw new Error(`zoo returned HTTP ${response.status}: ${text}`);
|
|
439
440
|
}
|
|
440
441
|
return { data: await response.json(), receipt };
|
package/lib/podagent.mjs
CHANGED
|
@@ -258,6 +258,19 @@ async function httpErrorNote(status) {
|
|
|
258
258
|
return status ? `(request failed — HTTP ${status})` : '';
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/** Do not dump raw Solana wrap-sim logs into the chat bubble. */
|
|
262
|
+
function sanitizeProxiedError(msg) {
|
|
263
|
+
if (!msg) return msg;
|
|
264
|
+
const s = String(msg);
|
|
265
|
+
if (/0x6a\b|custom program error:\s*106\b|NotEnoughAccounts/i.test(s)) {
|
|
266
|
+
return 'wrap ix has too few accounts (need 9); old 5-account wrap is dead';
|
|
267
|
+
}
|
|
268
|
+
if (/0x70\b|custom program error:\s*112\b|TokenProgramMismatch/i.test(s)) {
|
|
269
|
+
return 'unwrap ix is missing the unwrapped token program (account 8); 8-account unwrap is dead';
|
|
270
|
+
}
|
|
271
|
+
return s;
|
|
272
|
+
}
|
|
273
|
+
|
|
261
274
|
// A 402 that reached this layer means the proxy's own x402 retry gave up on
|
|
262
275
|
// this attempt, but the NEXT attempt usually settles (measured: same wallet,
|
|
263
276
|
// same rail, second call pays fine). Surfacing that as a chat message makes
|
|
@@ -407,7 +420,7 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
|
|
|
407
420
|
// fall back to the non-streaming path rather than fail outright
|
|
408
421
|
const j = await r.json().catch(() => ({}));
|
|
409
422
|
const content = j?.choices?.[0]?.message?.content;
|
|
410
|
-
const proxied = j?.error?.message;
|
|
423
|
+
const proxied = sanitizeProxiedError(j?.error?.message);
|
|
411
424
|
const text = content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status)));
|
|
412
425
|
if (text) onDelta(text);
|
|
413
426
|
return text;
|
|
@@ -753,7 +766,7 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
|
|
|
753
766
|
if (!r.ok || !r.body) {
|
|
754
767
|
const j = await r.json().catch(() => ({}));
|
|
755
768
|
const content = j?.choices?.[0]?.message?.content;
|
|
756
|
-
const proxied = j?.error?.message;
|
|
769
|
+
const proxied = sanitizeProxiedError(j?.error?.message);
|
|
757
770
|
const text = content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status)));
|
|
758
771
|
lastFail = { model: 'gateway', text: text || '', error: r.ok ? undefined : `HTTP ${r.status}` };
|
|
759
772
|
if (isRaceCountable(lastFail)) {
|
|
@@ -936,6 +949,7 @@ async function readGatewayRaceStream(r, feed, signal) {
|
|
|
936
949
|
*
|
|
937
950
|
* Every entrant is paid for, including the abandoned one — this trades money
|
|
938
951
|
* for latency and quality, which is why it is opt-in and capped.
|
|
952
|
+
* grokui does not grant unused or failed racers back to the user.
|
|
939
953
|
*
|
|
940
954
|
* `hooks` is for tests: `{ stream, classify, pairwise, minScore }`.
|
|
941
955
|
*/
|
package/lib/proxy.js
CHANGED
|
@@ -27,6 +27,7 @@ import { creditBalance, quotedPrices } from './info.js';
|
|
|
27
27
|
import { subscriptionPublicView } from './subscription.js';
|
|
28
28
|
import { priceHoldings } from './livestatus.js';
|
|
29
29
|
import { receiptUsedCogs } from './racesettle.js';
|
|
30
|
+
import { rewriteWrapClientError } from './wrap.js';
|
|
30
31
|
|
|
31
32
|
const HOP_BY_HOP = new Set([
|
|
32
33
|
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
@@ -1726,7 +1727,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1726
1727
|
// network error in `cause`. Surface it (and log the stack) or every
|
|
1727
1728
|
// transport hiccup looks identical to a payment bug.
|
|
1728
1729
|
const cause = err.cause?.message || err.cause?.code || err.cause;
|
|
1729
|
-
const
|
|
1730
|
+
const raw = cause ? `${err.message} (${cause})` : err.message;
|
|
1731
|
+
const detail = rewriteWrapClientError(raw);
|
|
1730
1732
|
log(`proxy error: ${detail}`);
|
|
1731
1733
|
if (process.env.OPENZOO_DEBUG) console.error(err.stack);
|
|
1732
1734
|
jsonErr(res, 502, `openzoo proxy error: ${detail}`);
|
package/lib/racesettle.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fly gateway race: one POST { race, race_need, tier }
|
|
3
|
-
*
|
|
2
|
+
* Fly gateway race: one POST { race, race_need, tier }.
|
|
3
|
+
* User pays for every entrant we actually launched. Failures still cost us
|
|
4
|
+
* (OpenRouter was paid). race_unused on a receipt is informational — do not
|
|
5
|
+
* treat it as a user refund or shrink HUD cogs to hide a house loss.
|
|
6
|
+
* HUD embers when cogs > spent.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
9
|
export const FLY_GATEWAY_HOST = 'x402-tokens.fly.dev';
|
|
@@ -92,40 +95,23 @@ export function capRaceByCredit(n, { creditUsd, quoteUsd } = {}) {
|
|
|
92
95
|
return { n: Math.min(want, maxN), reason: 'shrunk' };
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
function unusedGrant(x) {
|
|
96
|
-
const u = x?.race_unused ?? x?.raceUnused ?? x?.unused;
|
|
97
|
-
if (u == null) return { billed: 0, cogs: 0 };
|
|
98
|
-
if (typeof u === 'number' && Number.isFinite(u)) return { billed: u, cogs: u };
|
|
99
|
-
if (typeof u !== 'object') return { billed: 0, cogs: 0 };
|
|
100
|
-
const billed = Number(u.billedUsd ?? u.refundUsd ?? u.unusedBilledUsd ?? u.usd ?? 0);
|
|
101
|
-
const cogs = Number(u.cogsUsd ?? u.unusedCogsUsd ?? u.refundCogsUsd ?? billed);
|
|
102
|
-
return {
|
|
103
|
-
billed: Number.isFinite(billed) && billed > 0 ? billed : 0,
|
|
104
|
-
cogs: Number.isFinite(cogs) && cogs > 0 ? cogs : 0,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
98
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
99
|
+
* House cost from the receipt. Do not subtract race_unused — unused
|
|
100
|
+
* grant-back is not a user refund, and shrinking cogs would hide house loss.
|
|
101
|
+
* Does not clamp to billed — HUD embers when cogs exceed what was paid.
|
|
112
102
|
*/
|
|
113
103
|
export function receiptUsedCogs(x, markup = 3) {
|
|
114
104
|
if (!x || typeof x !== 'object') return 0;
|
|
115
105
|
const billedRaw = Number(x.billedUsd);
|
|
116
106
|
const billedOk = Number.isFinite(billedRaw) && billedRaw >= 0;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
: (billedOk ? billedRaw / markup : 0);
|
|
120
|
-
const grant = unusedGrant(x);
|
|
121
|
-
if (grant.cogs > 0) cogs = Math.max(0, cogs - grant.cogs);
|
|
122
|
-
return cogs;
|
|
107
|
+
if (typeof x.cogsUsd === 'number' && Number.isFinite(x.cogsUsd)) return x.cogsUsd;
|
|
108
|
+
return billedOk ? billedRaw / markup : 0;
|
|
123
109
|
}
|
|
124
110
|
|
|
125
111
|
/**
|
|
126
|
-
* Session meter. spent/direct are the receipt totals
|
|
127
|
-
*
|
|
128
|
-
* cogs is
|
|
112
|
+
* Session meter. spent/direct are the receipt totals as billed — never a
|
|
113
|
+
* first-call rewrite, never a race_unused user refund.
|
|
114
|
+
* cogs is the house cost on that receipt (HUD embers when cogs > spent).
|
|
129
115
|
*/
|
|
130
116
|
export function meterRaceReceipt(x, markup = 3) {
|
|
131
117
|
const billed = Number(x?.billedUsd);
|
package/lib/wrap.js
CHANGED
|
@@ -8,23 +8,45 @@
|
|
|
8
8
|
* shim. Nothing here is user-facing; never surface wrapped tickers or mints
|
|
9
9
|
* in messages that reach the user.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
11
|
+
* CLIENT BUILDER COPY — extra.acquire.steps / 402 help (WRAP_ACQUIRE_STEPS).
|
|
12
|
+
*
|
|
13
|
+
* Wrap ix has 9 accounts. 0x6a = 106 = NotEnoughAccounts, thrown at
|
|
14
|
+
* need(accounts, 9)?. Old 5-account Wrap is rejected.
|
|
15
|
+
*
|
|
16
|
+
* Program FrSERTNCPvTtaDS9AvQp9u1nYGzXDb3kC9MdL8Xxn2NE now CPIs the deposit.
|
|
17
|
+
* Delete the separate TransferChecked. Sending both double-transfers.
|
|
18
|
+
*
|
|
19
|
+
* data = [1] ++ u64 amount LE ++ [bump] (authority PDA bump, passed not derived)
|
|
20
|
+
*
|
|
21
|
+
* Accounts in exact order:
|
|
22
|
+
* 0 [writable] escrow (authority PDA ATA for UNDERLYING mint)
|
|
23
|
+
* 1 [writable] wrapped mint
|
|
24
|
+
* 2 [writable] recipient wrapped token account
|
|
25
|
+
* 3 [] wrapped mint authority PDA = PDA(["mint_authority", wrapped_mint], FrSER…)
|
|
26
|
+
* 4 [] wrapped token program (must equal wrapped_mint.owner; Token-2022 on shares)
|
|
27
|
+
* 5 [writable] depositor UNDERLYING token account
|
|
28
|
+
* 6 [signer] depositor (owner of account 5)
|
|
29
|
+
* 7 [] unwrapped mint
|
|
30
|
+
* 8 [] unwrapped token program (must equal escrow.owner)
|
|
31
|
+
*
|
|
32
|
+
* Account 4 and 8 are not interchangeable (LEOS: shares Token-2022, escrow
|
|
33
|
+
* TokenkegQ). Derive each ATA under its own token program.
|
|
34
|
+
* Prepend createAssociatedTokenAccountIdempotentInstruction for the recipient
|
|
35
|
+
* wrapped ATA. The program mints into it; it does not create it.
|
|
36
|
+
*
|
|
37
|
+
* Unwrap: 9 accounts, account 8 = unwrapped token program. 8 accounts → 0x70
|
|
38
|
+
* TokenProgramMismatch. Prepend idempotent ATA create for recipient underlying
|
|
39
|
+
* (may have been closed after wrapping).
|
|
40
|
+
*
|
|
41
|
+
* First deposit is 1:1 minus MINIMUM_LIQUIDITY (1000) locked forever.
|
|
42
|
+
* authority = PDA(['mint_authority', wrappedMint]); escrow = ATA(underlying,
|
|
43
|
+
* authority); registry = PDA(['backpointer', wrappedMint]) when present.
|
|
21
44
|
*/
|
|
22
45
|
import { PublicKey, Transaction, TransactionInstruction } from '@solana/web3.js';
|
|
23
46
|
import {
|
|
24
47
|
TOKEN_PROGRAM_ID,
|
|
25
48
|
TOKEN_2022_PROGRAM_ID,
|
|
26
49
|
createAssociatedTokenAccountIdempotentInstruction,
|
|
27
|
-
createTransferCheckedInstruction,
|
|
28
50
|
getAssociatedTokenAddressSync,
|
|
29
51
|
unpackAccount,
|
|
30
52
|
unpackMint,
|
|
@@ -34,6 +56,47 @@ export const WRAP_PROGRAM_ID = new PublicKey('FrSERTNCPvTtaDS9AvQp9u1nYGzXDb3kC9
|
|
|
34
56
|
const AUTHORITY_SEED = Buffer.from('mint_authority');
|
|
35
57
|
const BACKPOINTER_SEED = Buffer.from('backpointer');
|
|
36
58
|
export const MINIMUM_LIQUIDITY = 1000n;
|
|
59
|
+
export const WRAP_IX_ACCOUNT_COUNT = 9;
|
|
60
|
+
export const WRAP_TOO_FEW_ACCOUNTS =
|
|
61
|
+
'wrap ix has too few accounts (need 9); old 5-account wrap is dead';
|
|
62
|
+
|
|
63
|
+
/** extra.acquire.steps / 402 help — the copy clients build the Wrap ix from. */
|
|
64
|
+
export const WRAP_ACQUIRE_STEPS = {
|
|
65
|
+
method: 'spl-token-wrap',
|
|
66
|
+
program: 'FrSERTNCPvTtaDS9AvQp9u1nYGzXDb3kC9MdL8Xxn2NE',
|
|
67
|
+
help: 'Wrap ix has 9 accounts. Program CPIs the deposit — do not send a separate TransferChecked. 0x6a = NotEnoughAccounts (old 5-account wrap is dead).',
|
|
68
|
+
data: '[1] ++ u64 amount LE ++ [bump] (authority PDA bump, passed not derived)',
|
|
69
|
+
prepend: 'createAssociatedTokenAccountIdempotentInstruction for the recipient wrapped ATA',
|
|
70
|
+
accounts: [
|
|
71
|
+
{ i: 0, writable: true, name: 'escrow', note: 'authority PDA ATA for UNDERLYING mint' },
|
|
72
|
+
{ i: 1, writable: true, name: 'wrappedMint' },
|
|
73
|
+
{ i: 2, writable: true, name: 'recipientWrappedAta' },
|
|
74
|
+
{ i: 3, writable: false, name: 'mintAuthorityPda', note: 'PDA(["mint_authority", wrapped_mint], FrSER…)' },
|
|
75
|
+
{ i: 4, writable: false, name: 'wrappedTokenProgram', note: 'must equal wrapped_mint.owner; Token-2022 on shares' },
|
|
76
|
+
{ i: 5, writable: true, name: 'depositorUnderlyingAta' },
|
|
77
|
+
{ i: 6, writable: false, signer: true, name: 'depositor', note: 'owner of account 5' },
|
|
78
|
+
{ i: 7, writable: false, name: 'unwrappedMint' },
|
|
79
|
+
{ i: 8, writable: false, name: 'unwrappedTokenProgram', note: 'must equal escrow.owner; not interchangeable with account 4' },
|
|
80
|
+
],
|
|
81
|
+
unwrap: {
|
|
82
|
+
accounts: 9,
|
|
83
|
+
account8: 'unwrapped token program',
|
|
84
|
+
prepend: 'idempotent ATA create for recipient underlying (may have been closed)',
|
|
85
|
+
note: '8 accounts → 0x70 TokenProgramMismatch',
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Short 402-help / chat copy. Never dump raw Solana simulation logs. */
|
|
90
|
+
export function rewriteWrapClientError(message) {
|
|
91
|
+
const s = String(message ?? '');
|
|
92
|
+
if (/0x6a\b|custom program error:\s*106\b|NotEnoughAccounts/i.test(s)) {
|
|
93
|
+
return WRAP_TOO_FEW_ACCOUNTS;
|
|
94
|
+
}
|
|
95
|
+
if (/0x70\b|custom program error:\s*112\b|TokenProgramMismatch/i.test(s)) {
|
|
96
|
+
return 'unwrap ix is missing the unwrapped token program (account 8); 8-account unwrap is dead';
|
|
97
|
+
}
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
37
100
|
|
|
38
101
|
// Machine-readable per-asset acquire directory published by the facilitator.
|
|
39
102
|
// Consulted first so newly listed twins work with zero code changes; on-chain
|
|
@@ -172,27 +235,15 @@ export async function poolState(connection, pool) {
|
|
|
172
235
|
}
|
|
173
236
|
|
|
174
237
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* then move the deposit into escrow. `rentPayer` funds ATA creation (defaults
|
|
238
|
+
* ATA-create + 9-account Wrap. The program CPIs the deposit itself — there
|
|
239
|
+
* is no trailing TransferChecked. `rentPayer` funds ATA creation (defaults
|
|
178
240
|
* to the owner; the gateway feePayer when riding inside a payment tx).
|
|
179
241
|
*/
|
|
180
242
|
export function buildWrapInstructions({ pool, owner, depositRaw, rentPayer = owner }) {
|
|
181
243
|
const userWrapped = getAssociatedTokenAddressSync(pool.wrapped, owner, false, pool.wrappedProgram);
|
|
182
244
|
const userUnderlying = getAssociatedTokenAddressSync(pool.underlying, owner, false, pool.underlyingProgram);
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// This used to emit three instructions — ensure ATA, Wrap, then a separate
|
|
186
|
-
// TransferChecked moving the underlying into escrow — because the program
|
|
187
|
-
// only minted shares and trusted that the caller's own transfer would follow.
|
|
188
|
-
// Nothing enforced it. On 2026-08-18 a caller sent the Wrap instruction ALONE
|
|
189
|
-
// and minted shares backed by nothing, then unwrapped them: 829,559 TOKEN out
|
|
190
|
-
// of the vault, NAV 1 -> 0.000177.
|
|
191
|
-
//
|
|
192
|
-
// The deployed program (slot 440219442) now CPIs the transfer itself, so the
|
|
193
|
-
// Wrap instruction carries the depositor's source account and signature and
|
|
194
|
-
// the separate transfer is GONE. A 5-account call is rejected outright with
|
|
195
|
-
// NotEnoughAccounts (0x6a) — verified against mainnet by simulation.
|
|
245
|
+
// 9-account Wrap. Program CPIs the deposit. No TransferChecked after this.
|
|
246
|
+
// Old 5-account Wrap is rejected 0x6a (NotEnoughAccounts).
|
|
196
247
|
const wrapIx = new TransactionInstruction({
|
|
197
248
|
programId: pool.programId || WRAP_PROGRAM_ID,
|
|
198
249
|
keys: [
|
|
@@ -254,6 +305,14 @@ export async function sendWrap(connection, keypair, pool, depositRaw) {
|
|
|
254
305
|
tx.recentBlockhash = blockhash;
|
|
255
306
|
tx.feePayer = keypair.publicKey;
|
|
256
307
|
tx.sign(keypair);
|
|
257
|
-
|
|
258
|
-
|
|
308
|
+
try {
|
|
309
|
+
const sig = await connection.sendRawTransaction(tx.serialize());
|
|
310
|
+
return confirmSignatureByPolling(connection, sig, { commitment: 'confirmed' });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
const rewritten = rewriteWrapClientError(err?.message || String(err));
|
|
313
|
+
if (rewritten !== (err?.message || String(err))) {
|
|
314
|
+
throw new Error(rewritten);
|
|
315
|
+
}
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
259
318
|
}
|
package/lib/x402.js
CHANGED
|
@@ -22,12 +22,18 @@ import {
|
|
|
22
22
|
* payTo: "<wallet>", resource, description, maxTimeoutSeconds,
|
|
23
23
|
* extra: { facilitator, feePayer, symbol, billedUsd, tokenUsd,
|
|
24
24
|
* pricedAt, pricing: "markup"|"counterfactual",
|
|
25
|
-
* markup? , directUsd?, savesVsDirect
|
|
26
|
-
*
|
|
25
|
+
* markup? , directUsd?, savesVsDirect?,
|
|
26
|
+
* acquire?: { method: "spl-token-wrap", steps: WRAP_ACQUIRE_STEPS } } } ],
|
|
27
|
+
* error: "payment required",
|
|
28
|
+
* help: "Wrap ix has 9 accounts. Program CPIs the deposit — do not send a separate TransferChecked. 0x6a = NotEnoughAccounts (old 5-account wrap is dead)." }
|
|
27
29
|
*
|
|
28
|
-
* Payment: ONE Token-2022 TransferChecked
|
|
29
|
-
*
|
|
30
|
-
* partial-signed by the payer,
|
|
30
|
+
* Payment of the quoted (already-wrapped) mint: ONE Token-2022 TransferChecked
|
|
31
|
+
* (payer ATA -> payTo ATA) for exactly maxAmountRequired, feePayer =
|
|
32
|
+
* extra.feePayer (the gateway pays SOL fees), partial-signed by the payer,
|
|
33
|
+
* serialized requireAllSignatures=false, base64.
|
|
34
|
+
* Funding a short wrapped balance is a *separate* 9-account Wrap prepended as
|
|
35
|
+
* preInstructions (lib/wrap.js WRAP_ACQUIRE_STEPS). Do not follow Wrap with a
|
|
36
|
+
* deposit TransferChecked — the program pulls the underlying itself.
|
|
31
37
|
* X-PAYMENT header = base64 of
|
|
32
38
|
* {"x402Version":1,"scheme":"exact","network":"<network>","payload":{"transaction":"<b64 tx>"}}
|
|
33
39
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.4",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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",
|