openzoo 0.48.23 → 0.48.24

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/lib/info.js CHANGED
@@ -24,8 +24,53 @@ function fmtUi(raw, decimals) {
24
24
  * price is known ($1 stables); everything else gets an honest `?`.
25
25
  * A chain whose RPC does not answer prints as unreachable — never as zero.
26
26
  */
27
+ /**
28
+ * USD per whole token, by SYMBOL, straight from the 402 quote.
29
+ *
30
+ * The quote already carries `extra.tokenUsd` for every asset it settles in —
31
+ * the very "priced at the 402" this file kept promising in a footnote while
32
+ * printing `$?`. So a wallet holding 845,486 TOKEN summed to "≈ $0.11 known".
33
+ * Symbols come back wrapped (wTOKENx, wLEOSx, wUSDGx); strip the wrapper so a
34
+ * row for the plain token the user actually holds finds its price.
35
+ */
36
+ async function quotedPrices() {
37
+ const out = {};
38
+ try {
39
+ // Imported here, not at module scope, matching affordableUsd below — this
40
+ // file is loaded by `openzoo address`, which must work with no network.
41
+ const { withNamespace } = await import('./namespace.js');
42
+ const r = await fetch(`${config.apiBase}/v1/credits/topup`, {
43
+ method: 'POST',
44
+ headers: withNamespace({ 'content-type': 'application/json' }),
45
+ body: JSON.stringify({ usd: 1 }),
46
+ });
47
+ if (r.status !== 402) return out;
48
+ const ch = await r.json().catch(() => ({}));
49
+ for (const row of ch.accepts || []) {
50
+ const usd = Number(row?.extra?.tokenUsd);
51
+ const sym = String(row?.extra?.symbol || '');
52
+ if (!sym || !Number.isFinite(usd)) continue;
53
+ // wTOKENx -> TOKEN, wUSDGx -> USDG, yUSDCx -> USDC
54
+ const plain = sym.replace(/^[wy]/, '').replace(/x$/, '').toUpperCase();
55
+ out[sym.toUpperCase()] = usd;
56
+ if (!(plain in out)) out[plain] = usd;
57
+ }
58
+ } catch { /* offline: fall back to printing what we know */ }
59
+ return out;
60
+ }
61
+
27
62
  export async function printBalance() {
28
63
  const { keypair, evmPrivateKey } = loadOrCreateWallet();
64
+ const px = await quotedPrices();
65
+ const priceOf = (sym) => px[String(sym).toUpperCase()];
66
+ const show = (sym, ui) => {
67
+ const p = priceOf(sym);
68
+ if (p == null) return ' ($?)';
69
+ const v = ui * p;
70
+ // Sub-cent holdings read as "$0.00", which is indistinguishable from
71
+ // worthless; show enough digits that a real balance is visible.
72
+ return ` ($${v >= 0.01 || v === 0 ? v.toFixed(2) : v.toFixed(6)})`;
73
+ };
29
74
  const evmAddress = privateKeyToAccount(evmPrivateKey).address;
30
75
  const connection = new Connection(config.rpcUrl, 'confirmed');
31
76
 
@@ -34,14 +79,17 @@ export async function printBalance() {
34
79
  Promise.all(FUNDING_ASSETS.map((a) => tokenBalance(connection, keypair.publicKey, a.mint))),
35
80
  connection.getBalance(keypair.publicKey),
36
81
  ]);
37
- const usdcUi = balances[0].ui ?? 0;
38
- let knownUsd = usdcUi;
82
+ // Seeded at zero: every asset is priced from the quote below, so seeding
83
+ // with USDC would count it twice.
84
+ let knownUsd = 0;
39
85
  let anyFunds = balances.some((b) => b.raw);
40
86
 
41
87
  console.log(`Solana — ${keypair.publicKey.toBase58()}`);
42
88
  FUNDING_ASSETS.forEach((a, i) => {
43
- const usd = a.symbol === 'USDC' ? ` ($${(balances[i].ui ?? 0).toFixed(2)})` : ' ($?, valued at the 402)';
44
- console.log(` ${a.symbol.padEnd(11)}: ${balances[i].ui ?? 0}${usd}`);
89
+ const ui = balances[i].ui ?? 0;
90
+ const p = priceOf(a.symbol);
91
+ if (p != null) knownUsd += ui * p;
92
+ console.log(` ${a.symbol.padEnd(11)}: ${ui}${show(a.symbol, ui)}`);
45
93
  });
46
94
  console.log(` ${'SOL'.padEnd(11)}: ${lamports / 1e9} (gas — optional, payments are sponsored)`);
47
95
 
@@ -57,9 +105,9 @@ export async function printBalance() {
57
105
  assets.forEach((a, i) => {
58
106
  const ui = fmtUi(raws[i], a.decimals);
59
107
  if (raws[i] > 0n) anyFunds = true;
60
- if (a.usd != null) knownUsd += ui * a.usd;
61
- const usd = a.usd != null ? ` ($${(ui * a.usd).toFixed(2)})` : ' ($?)';
62
- console.log(` ${a.symbol.padEnd(11)}: ${ui}${usd}`);
108
+ const p = priceOf(a.symbol) ?? a.usd;
109
+ if (p != null) knownUsd += ui * p;
110
+ console.log(` ${a.symbol.padEnd(11)}: ${ui}${p != null ? show(a.symbol, ui) : ' ($?)'}`);
63
111
  });
64
112
  console.log(` ${'ETH'.padEnd(11)}: ${fmtUi(native, 18)} (gas — optional, payments are sponsored)`);
65
113
  } catch {
@@ -67,7 +115,7 @@ export async function printBalance() {
67
115
  }
68
116
  }
69
117
 
70
- console.log(`value : ≈ $${knownUsd.toFixed(2)} known (stable legs; TOKEN and the RH memecoins are priced at the 402, shown as $?)`);
118
+ console.log(`value : ≈ $${knownUsd.toFixed(2)}${Object.keys(px).length ? ' (every asset priced at the 402)' : ' known (402 unreachable unpriced assets shown as $?)'}`);
71
119
  if (!anyFunds) {
72
120
  console.log(`fund : ${fundingLine(keypair.publicKey.toBase58())}`);
73
121
  console.log(` or USDC on Base to ${evmAddress}`);
@@ -110,10 +158,20 @@ export async function affordableUsd() {
110
158
  const perUsd = BigInt(row.maxAmountRequired || '0');
111
159
  if (perUsd <= 0n) continue;
112
160
  try {
113
- const bal = await client.balanceForAccept?.(row);
114
- const raw = typeof bal === 'bigint' ? bal : BigInt(bal?.raw ?? 0);
115
- const usd = Number(raw * 1000n / perUsd) / 1000;
116
- if (usd > best) best = usd;
161
+ // SPENDABLE, not merely wrapped. Every quote names the WRAPPER, and the
162
+ // plain token is converted at payment time (topUpQuotedAsset /
163
+ // acquireWrappedIfNeeded) so counting only pre-wrapped balance told a
164
+ // user holding 14,847 TOKEN that their wallet "covers only $1.0230" and
165
+ // sent them off to fund an account that was already funded.
166
+ let usd;
167
+ if (client.spendableUsdForAccept) {
168
+ usd = await client.spendableUsdForAccept(row);
169
+ } else {
170
+ const bal = await client.balanceForAccept?.(row);
171
+ const raw = typeof bal === 'bigint' ? bal : BigInt(bal?.raw ?? 0);
172
+ usd = Number(raw * 1000n / perUsd) / 1000;
173
+ }
174
+ if (Number.isFinite(usd) && usd > best) best = usd;
117
175
  } catch { /* a rail we cannot read is simply not a candidate */ }
118
176
  }
119
177
  return best;
package/lib/pay.js CHANGED
@@ -150,6 +150,66 @@ export class PayClient {
150
150
  return BigInt(raw || 0);
151
151
  }
152
152
 
153
+ /**
154
+ * What this wallet can actually PAY with on this rail, in settlement-asset
155
+ * units — wrapped holdings PLUS the plain token it can convert on the fly.
156
+ *
157
+ * balanceForAccept answers a different question: how much of the WRAPPED
158
+ * asset is sitting there right now. Every quote names the wrapper (wTOKENx,
159
+ * the RH vault twins), so a wallet holding 845,486 plain TOKEN read as
160
+ * "$3.14 covered" — only the sliver already wrapped — while `openzoo balance`
161
+ * showed the full holding priced as `$?`. Two different wrong answers about
162
+ * the same wallet, and a user was told to fund an account with $177 in it.
163
+ *
164
+ * buildPaymentFor already wraps what it needs at payment time (topUpQuotedAsset
165
+ * on Solana, acquireWrappedIfNeeded on EVM), so the convertible balance IS
166
+ * spendable and the estimate was simply lying by omission. Advisory only:
167
+ * anything unreadable degrades to the wrapped figure rather than throwing,
168
+ * because this feeds a display and a top-up hint, never a settlement.
169
+ */
170
+ async spendableUsdForAccept(accept) {
171
+ const perUsd = BigInt(accept.maxAmountRequired || '0');
172
+ if (perUsd <= 0n) return 0;
173
+ const wrappedRaw = await this.balanceForAccept(accept).catch(() => 0n);
174
+ let usd = Number(wrappedRaw * 1000n / perUsd) / 1000;
175
+
176
+ // Price the PLAIN token with the quote's own tokenUsd, NOT with the pool's
177
+ // supply/reserves ratio. That ratio is share accounting, not a price: the
178
+ // live wTOKENx pool reads supply/reserves ≈ 5659, so treating it as a
179
+ // conversion rate valued 845,486 TOKEN at $1,004,132. The 402 already
180
+ // publishes what a whole token is worth, and the wrapper is a claim on the
181
+ // same asset — so underlying and wrapped are priced identically, which is
182
+ // exactly what `openzoo balance` now shows.
183
+ const px = Number(accept?.extra?.tokenUsd);
184
+ if (!Number.isFinite(px) || px <= 0) return usd;
185
+ const rail = railOf(accept);
186
+ try {
187
+ if (rail === 'solana') {
188
+ const pool = await resolvePool(this.connection, accept.asset);
189
+ if (!pool) return usd;
190
+ const under = await tokenBalance(this.connection, this.keypair.publicKey,
191
+ pool.underlying.toBase58());
192
+ usd += Number(under?.ui || 0) * px;
193
+ return usd;
194
+ }
195
+ // EVM wrappers are ERC-4626; asset() names the plain token behind them.
196
+ const { createPublicClient, http } = await import('viem');
197
+ const pc = createPublicClient({ transport: http(evmRpcFor(rail)) });
198
+ const ABI = [
199
+ { name: 'asset', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
200
+ { name: 'decimals', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
201
+ ];
202
+ const under = await pc.readContract({ address: accept.asset, abi: ABI, functionName: 'asset', args: [] });
203
+ const raw = await evmTokenBalance({ rpcUrl: evmRpcFor(rail), token: under, owner: this.evmAddress });
204
+ if (!raw || raw <= 0n) return usd;
205
+ const dec = Number(accept?.extra?.decimals ?? 18);
206
+ usd += (Number(raw) / 10 ** dec) * px;
207
+ return usd;
208
+ } catch {
209
+ return usd; // not a wrapper, or a rail we cannot read
210
+ }
211
+ }
212
+
153
213
  async buildPaymentFor(accept, onStage) {
154
214
  const rail = railOf(accept);
155
215
  if (rail === 'solana') {
package/lib/wrap.js CHANGED
@@ -180,6 +180,19 @@ export async function poolState(connection, pool) {
180
180
  export function buildWrapInstructions({ pool, owner, depositRaw, rentPayer = owner }) {
181
181
  const userWrapped = getAssociatedTokenAddressSync(pool.wrapped, owner, false, pool.wrappedProgram);
182
182
  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.
183
196
  const wrapIx = new TransactionInstruction({
184
197
  programId: pool.programId || WRAP_PROGRAM_ID,
185
198
  keys: [
@@ -188,16 +201,17 @@ export function buildWrapInstructions({ pool, owner, depositRaw, rentPayer = own
188
201
  { pubkey: userWrapped, isSigner: false, isWritable: true },
189
202
  { pubkey: pool.authority, isSigner: false, isWritable: false },
190
203
  { pubkey: pool.wrappedProgram, isSigner: false, isWritable: false },
204
+ // the deposit the program will pull, and who authorises it
205
+ { pubkey: userUnderlying, isSigner: false, isWritable: true },
206
+ { pubkey: owner, isSigner: true, isWritable: false },
207
+ { pubkey: pool.underlying, isSigner: false, isWritable: false },
208
+ { pubkey: pool.underlyingProgram, isSigner: false, isWritable: false },
191
209
  ],
192
210
  data: Buffer.concat([Buffer.from([1]), u64le(depositRaw), Buffer.from([pool.bump])]),
193
211
  });
194
212
  return [
195
213
  createAssociatedTokenAccountIdempotentInstruction(rentPayer, userWrapped, owner, pool.wrapped, pool.wrappedProgram),
196
214
  wrapIx,
197
- createTransferCheckedInstruction(
198
- userUnderlying, pool.underlying, pool.escrow, owner,
199
- depositRaw, pool.underlyingDecimals, [], pool.underlyingProgram,
200
- ),
201
215
  ];
202
216
  }
203
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.23",
3
+ "version": "0.48.24",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — 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",