openzoo 0.49.3 → 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 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 settlement mint as internal plumbing; you only ever hold and send USDC or TOKEN. |
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/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)) {
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 detail = cause ? `${err.message} (${cause})` : err.message;
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/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
- * Verified against the deployed program's own e2e (solana-token-wrap/e2e/e2e.mjs)
12
- * and live mainnet wrap transactions:
13
- * wrap ix: data = [1][amount u64 LE][authority bump]
14
- * keys = [escrow(w), wrappedMint(w), userWrappedAta(w),
15
- * authorityPDA, wrappedTokenProgram]
16
- * shares minted = floor(amount * supply / reserves), reserves read at ix
17
- * execution so the wrap ix is placed BEFORE the deposit TransferChecked.
18
- * First deposit is 1:1 minus MINIMUM_LIQUIDITY (1000) locked forever.
19
- * authority = PDA(['mint_authority', wrappedMint]); escrow = ATA(underlying,
20
- * authority); registry = PDA(['backpointer', wrappedMint]) when present.
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
- * The three instructions of a conversion, in the mainnet-proven order:
176
- * ensure the wrapped ATA, mint shares (program reads pre-deposit reserves),
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
- // NINE ACCOUNTS, AND THE PROGRAM PULLS THE DEPOSIT ITSELF.
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
- const sig = await connection.sendRawTransaction(tx.serialize());
258
- return confirmSignatureByPolling(connection, sig, { commitment: 'confirmed' });
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
- * error: "payment required", help: "..." }
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 (payer ATA -> payTo ATA) for exactly
29
- * maxAmountRequired, feePayer = extra.feePayer (the gateway pays SOL fees),
30
- * partial-signed by the payer, serialized requireAllSignatures=false, base64.
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",
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",