openzoo 0.1.0 → 0.2.0
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 +31 -9
- package/bin/openzoo.js +2 -2
- package/lib/config.js +1 -1
- package/lib/demo.js +53 -17
- package/lib/info.js +10 -9
- package/lib/mcp.js +9 -8
- package/lib/pay.js +79 -11
- package/lib/proxy.js +4 -5
- package/lib/spinner.js +85 -0
- package/lib/wrap.js +208 -0
- package/lib/x402.js +5 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,25 @@ curl http://localhost:8402/v1/chat/completions -H 'Content-Type: application/jso
|
|
|
40
40
|
-d '{"model":"nvidia/nemotron-3.5-lightning","messages":[{"role":"user","content":"hi"}]}'
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
**OpenAI SDK (Python):**
|
|
44
|
+
```python
|
|
45
|
+
from openai import OpenAI
|
|
46
|
+
client = OpenAI(base_url="http://localhost:8402/v1", api_key="sk-openzoo")
|
|
47
|
+
r = client.chat.completions.create(model="nvidia/nemotron-3.5-lightning",
|
|
48
|
+
messages=[{"role": "user", "content": "hi"}])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**OpenAI SDK (JS/TS):**
|
|
52
|
+
```js
|
|
53
|
+
import OpenAI from "openai";
|
|
54
|
+
const client = new OpenAI({ baseURL: "http://localhost:8402/v1", apiKey: "sk-openzoo" });
|
|
55
|
+
const r = await client.chat.completions.create({
|
|
56
|
+
model: "nvidia/nemotron-3.5-lightning",
|
|
57
|
+
messages: [{ role: "user", content: "hi" }],
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Model ids come from `GET http://localhost:8402/v1/models` (free, no payment). Requires Node ≥ 18 (for `npx openzoo` itself; your harness can be anything).
|
|
44
62
|
|
|
45
63
|
## Use as MCP
|
|
46
64
|
|
|
@@ -50,12 +68,16 @@ The same package is an MCP server sharing the same wallet and payment core:
|
|
|
50
68
|
claude mcp add openzoo -- npx -y openzoo mcp
|
|
51
69
|
```
|
|
52
70
|
|
|
53
|
-
|
|
71
|
+
**Claude Desktop** — add to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`, Windows: `%APPDATA%\Claude\claude_desktop_config.json`), then restart the app:
|
|
54
72
|
|
|
55
73
|
```json
|
|
56
74
|
{ "mcpServers": { "openzoo": { "command": "npx", "args": ["-y", "openzoo", "mcp"] } } }
|
|
57
75
|
```
|
|
58
76
|
|
|
77
|
+
**Cursor** — Settings → MCP → *Add new global MCP server*, or drop the same JSON into `~/.cursor/mcp.json` (per-project: `.cursor/mcp.json`).
|
|
78
|
+
|
|
79
|
+
**Windsurf / Cline / any MCP host** — same shape: command `npx`, args `["-y", "openzoo", "mcp"]`, stdio transport.
|
|
80
|
+
|
|
59
81
|
Tools:
|
|
60
82
|
- **`zoo_ask`** — `{prompt, corpus?, model?, max_tokens?}`. The flagship: hand it a *huge* `corpus` (hundreds of thousands to ~1M tokens — a body the model itself would refuse) and a question; the zoo's leCore memory spills the corpus so the model reads only a few thousand tokens. Returns the answer plus the receipt (`billedUsd`, `savesVsDirect`, tokens actually read). An MCP-capable agent can delegate a giant-context question without touching its own model config.
|
|
61
83
|
- **`zoo_models`** — list the zoo's models and pricing (free).
|
|
@@ -70,18 +92,18 @@ npx openzoo demo
|
|
|
70
92
|
Builds a ~965k-token document with one planted fact, shows that buying direct refuses it (*"The input token count exceeds the maximum number of tokens allowed."* — recorded, see [benches.openzoo.fun](https://benches.openzoo.fun)), then fetches the live x402 quote for the same body — the quote is free:
|
|
71
93
|
|
|
72
94
|
```
|
|
73
|
-
quote: $0.009747
|
|
95
|
+
quote: $0.009747 · pricing=counterfactual · direct would be $0.097474 · savesVsDirect=10.0×
|
|
74
96
|
```
|
|
75
97
|
|
|
76
|
-
If the wallet is funded it pays (capped at `OPENZOO_DEMO_MAX_USD`, default $0.01) and prints the answer, the tokens the model actually read, and the receipt. If not, it prints exactly what to fund.
|
|
98
|
+
If the wallet is funded with USDC it pays (capped at `OPENZOO_DEMO_MAX_USD`, default $0.01) and prints the answer, the tokens the model actually read, and the receipt. If not, it prints exactly what to fund. Long waits (the multi-MB upload, pricing, payment, the answer) show a live progress line with stage + elapsed seconds.
|
|
77
99
|
|
|
78
100
|
## The wallet model
|
|
79
101
|
|
|
80
102
|
- **Burner, local, yours.** A keypair in `~/.openzoo/wallet.json`, created on first run, chmod 600. Keys never leave your machine — the zoo only ever sees signed transfers.
|
|
81
|
-
- **Fund it with
|
|
82
|
-
- **
|
|
103
|
+
- **Fund it with plain USDC.** Send a few cents of USDC to the address `npx openzoo address` prints. That's the whole funding story — the shim converts whatever the 402 quotes internally, at payment time, for exactly the amount needed.
|
|
104
|
+
- **SOL is optional but nice.** The gateway sponsors payment-transaction fees. If the wallet holds a pinch of SOL (~0.003), the internal conversion settles as its own transaction first; with zero SOL the conversion rides inside the gateway-sponsored payment transaction instead.
|
|
83
105
|
- **Spend caps.** The proxy refuses any single quote above `OPENZOO_MAX_USD_PER_CALL` (default $0.50).
|
|
84
|
-
- `npx openzoo balance` / `npx openzoo address` — check funds / print the address.
|
|
106
|
+
- `npx openzoo balance` / `npx openzoo address` — check funds (USDC + SOL + USD value) / print the address.
|
|
85
107
|
|
|
86
108
|
## Honest pricing note
|
|
87
109
|
|
|
@@ -95,7 +117,7 @@ The receipt names which base you got; `extra.directUsd` / `extra.savesVsDirect`
|
|
|
95
117
|
|
|
96
118
|
| rail | network | status |
|
|
97
119
|
|---|---|---|
|
|
98
|
-
| **Solana** (default) | `solana:5eykt…` | **live** — Token-2022 `TransferChecked`, partial-signed, gateway pays fees. Tested end-to-end against the production 402. |
|
|
120
|
+
| **Solana** (default) | `solana:5eykt…` | **live** — Token-2022 `TransferChecked`, partial-signed, gateway pays fees. Tested end-to-end against the production 402. Settlement uses a wrapped settlement mint as internal plumbing; you only ever hold and send USDC. |
|
|
99
121
|
| Base | `eip155:8453` | implemented (standard x402 EIP-3009 `transferWithAuthorization`), **live-untested** — the zoo's 402s currently offer only Solana rows. |
|
|
100
122
|
| Robinhood Chain | `eip155:4663` | experimental, behind `OPENZOO_ENABLE_RH=1` — the zoo ships this rail dark and facilitator settlement there is unverified. |
|
|
101
123
|
|
|
@@ -108,7 +130,7 @@ The rail is chosen from the 402's `accepts[]` itself (Solana first). Amounts are
|
|
|
108
130
|
| `OPENZOO_PORT` | `8402` | proxy port |
|
|
109
131
|
| `OPENZOO_API_BASE` | `https://x402-tokens.fly.dev` | the zoo's door |
|
|
110
132
|
| `OPENZOO_RPC` | mainnet-beta public RPC | Solana RPC |
|
|
111
|
-
| `OPENZOO_TOKEN` |
|
|
133
|
+
| `OPENZOO_TOKEN` | (internal) | preferred 402 rail — leave unset |
|
|
112
134
|
| `OPENZOO_WALLET` | `~/.openzoo/wallet.json` | wallet path |
|
|
113
135
|
| `OPENZOO_MAX_USD_PER_CALL` | `0.5` | refuse quotes above this |
|
|
114
136
|
| `OPENZOO_DEMO_MAX_USD` | `0.01` | demo spend cap |
|
package/bin/openzoo.js
CHANGED
|
@@ -7,7 +7,7 @@ usage:
|
|
|
7
7
|
npx openzoo start the proxy on http://localhost:8402/v1
|
|
8
8
|
npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_models, zoo_wallet)
|
|
9
9
|
npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
|
|
10
|
-
npx openzoo balance wallet
|
|
10
|
+
npx openzoo balance wallet balance (USDC + SOL, with USD value)
|
|
11
11
|
npx openzoo address print the funding address
|
|
12
12
|
npx openzoo help this text
|
|
13
13
|
|
|
@@ -17,7 +17,7 @@ point any OpenAI-compatible harness at:
|
|
|
17
17
|
|
|
18
18
|
env:
|
|
19
19
|
OPENZOO_PORT (8402) OPENZOO_API_BASE (https://x402-tokens.fly.dev)
|
|
20
|
-
OPENZOO_RPC (mainnet-beta) OPENZOO_TOKEN (
|
|
20
|
+
OPENZOO_RPC (mainnet-beta) OPENZOO_TOKEN (402 rail preference) OPENZOO_WALLET (~/.openzoo/wallet.json)
|
|
21
21
|
OPENZOO_MAX_USD_PER_CALL (0.5) OPENZOO_DEMO_MAX_USD (0.01)
|
|
22
22
|
OPENZOO_ENABLE_RH (0 — Robinhood Chain rail, experimental)`;
|
|
23
23
|
|
package/lib/config.js
CHANGED
|
@@ -5,7 +5,7 @@ export const config = {
|
|
|
5
5
|
port: Number(process.env.OPENZOO_PORT || 8402),
|
|
6
6
|
apiBase: (process.env.OPENZOO_API_BASE || 'https://x402-tokens.fly.dev').replace(/\/+$/, ''),
|
|
7
7
|
rpcUrl: process.env.OPENZOO_RPC || 'https://api.mainnet-beta.solana.com',
|
|
8
|
-
// Which accepts[] row to pay with, by extra.symbol
|
|
8
|
+
// Which accepts[] row to pay with, by extra.symbol (internal rail selector).
|
|
9
9
|
token: process.env.OPENZOO_TOKEN || 'yUSDCx',
|
|
10
10
|
walletPath: process.env.OPENZOO_WALLET || path.join(os.homedir(), '.openzoo', 'wallet.json'),
|
|
11
11
|
// Refuse to auto-pay any single 402 quote above this many USD (extra.billedUsd).
|
package/lib/demo.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { config } from './config.js';
|
|
2
|
-
import { PayClient } from './pay.js';
|
|
2
|
+
import { PayClient, UnderfundedError } from './pay.js';
|
|
3
3
|
import { parse402, pickAccept, tokenBalance } from './x402.js';
|
|
4
|
+
import { startSpinner, postWithUploadSignal } from './spinner.js';
|
|
5
|
+
import { resolvePool, poolState, depositForShares } from './wrap.js';
|
|
4
6
|
|
|
5
7
|
const DEMO_MODEL = process.env.OPENZOO_DEMO_MODEL || 'nvidia/nemotron-3.5-lightning';
|
|
6
8
|
const NEEDLE = 'The zebra vault code is 7-ALPHA-9243.';
|
|
@@ -28,13 +30,32 @@ function bigBody(targetTokens) {
|
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
async function quoteFor(bodyObj) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
const body = JSON.stringify(bodyObj);
|
|
34
|
+
const mb = (body.length / 1048576).toFixed(1);
|
|
35
|
+
const approxTokens = Math.round(body.length / 4 / 1000);
|
|
36
|
+
const spin = startSpinner(`uploading ${mb}MB body`);
|
|
37
|
+
try {
|
|
38
|
+
const r = await postWithUploadSignal(`${config.apiBase}/v1/chat/completions`, body, {
|
|
39
|
+
onUploaded: () => spin.update(`zoo pricing ~${approxTokens}k tokens`),
|
|
40
|
+
});
|
|
41
|
+
if (r.status !== 402) throw new Error(`expected 402 quote, got HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
|
|
42
|
+
return parse402(await r.json());
|
|
43
|
+
} finally {
|
|
44
|
+
spin.stop();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Can the wallet cover this quote (directly or by converting its USDC)? */
|
|
49
|
+
async function coverage(connection, owner, accept) {
|
|
50
|
+
const need = BigInt(accept.maxAmountRequired);
|
|
51
|
+
const have = await tokenBalance(connection, owner, accept.asset);
|
|
52
|
+
if (have.raw >= need) return { covered: true, usdcUi: null };
|
|
53
|
+
const pool = await resolvePool(connection, accept.asset).catch(() => null);
|
|
54
|
+
if (!pool) return { covered: false, usdcUi: 0 };
|
|
55
|
+
const { reserves, supply } = await poolState(connection, pool);
|
|
56
|
+
const deposit = depositForShares(need - have.raw, reserves, supply);
|
|
57
|
+
const underlying = await tokenBalance(connection, owner, pool.underlying.toBase58());
|
|
58
|
+
return { covered: underlying.raw >= deposit, usdcUi: underlying.ui ?? 0 };
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
export async function runDemo() {
|
|
@@ -61,7 +82,7 @@ export async function runDemo() {
|
|
|
61
82
|
const quote = await quoteFor(bodyObj);
|
|
62
83
|
const accept = pickAccept(quote, config.token);
|
|
63
84
|
const x = accept.extra || {};
|
|
64
|
-
console.log(` quote: $${Number(x.billedUsd).toFixed(6)}
|
|
85
|
+
console.log(` quote: $${Number(x.billedUsd).toFixed(6)} · pricing=${x.pricing}`
|
|
65
86
|
+ (x.directUsd != null ? ` · direct would be $${Number(x.directUsd).toFixed(6)} · savesVsDirect=${Number(x.savesVsDirect).toFixed(1)}×` : ''));
|
|
66
87
|
console.log('');
|
|
67
88
|
|
|
@@ -78,20 +99,35 @@ export async function runDemo() {
|
|
|
78
99
|
payAccept = pickAccept(q2, config.token);
|
|
79
100
|
}
|
|
80
101
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
if (bal.raw < need) {
|
|
102
|
+
const cov = await coverage(connection, keypair.publicKey, payAccept);
|
|
103
|
+
if (!cov.covered) {
|
|
84
104
|
console.log('3) wallet not funded yet, so stopping before payment.');
|
|
85
|
-
console.log(`
|
|
86
|
-
console.log(`
|
|
87
|
-
console.log(` fund
|
|
88
|
-
console.log('
|
|
105
|
+
console.log(` quote : ≈ $${Number(payAccept.extra?.billedUsd).toFixed(6)}`);
|
|
106
|
+
console.log(` wallet: $${(cov.usdcUi ?? 0).toFixed(2)} USDC`);
|
|
107
|
+
console.log(` fund : send a few cents of USDC to ${pub}`);
|
|
108
|
+
console.log(' then re-run `npx openzoo demo`.');
|
|
89
109
|
return;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
console.log('3) paying and asking...');
|
|
113
|
+
const paidMb = (JSON.stringify(payBody).length / 1048576).toFixed(1);
|
|
114
|
+
const spin = startSpinner(`re-sending ${paidMb}MB body for the paid call`);
|
|
93
115
|
const t0 = Date.now();
|
|
94
|
-
|
|
116
|
+
let data; let receipt;
|
|
117
|
+
try {
|
|
118
|
+
({ data, receipt } = await client.chat(payBody, {
|
|
119
|
+
onStage: (stage) => {
|
|
120
|
+
if (stage === 'quoted') spin.update('building the payment');
|
|
121
|
+
if (stage === 'funding') spin.update('preparing funds (one-time)');
|
|
122
|
+
if (stage === 'paying') spin.update('paying + waiting for the answer');
|
|
123
|
+
},
|
|
124
|
+
}));
|
|
125
|
+
} catch (err) {
|
|
126
|
+
spin.stop();
|
|
127
|
+
if (err instanceof UnderfundedError) { console.log(` ${err.message}`); return; }
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
spin.stop();
|
|
95
131
|
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
|
96
132
|
const answer = data?.choices?.[0]?.message?.content ?? '';
|
|
97
133
|
const usage = data?.usage || {};
|
package/lib/info.js
CHANGED
|
@@ -4,8 +4,7 @@ import { config } from './config.js';
|
|
|
4
4
|
import { loadOrCreateWallet } from './wallet.js';
|
|
5
5
|
import { tokenBalance } from './x402.js';
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
const WTOKENX = 'Bo7xBF7SY8EyUBPUxRP66SFafxoPf2n5uqiLjbxEebx9';
|
|
7
|
+
const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
9
8
|
|
|
10
9
|
export function printAddress() {
|
|
11
10
|
const { keypair, evmPrivateKey, created, path } = loadOrCreateWallet();
|
|
@@ -17,14 +16,16 @@ export function printAddress() {
|
|
|
17
16
|
export async function printBalance() {
|
|
18
17
|
const { keypair } = loadOrCreateWallet();
|
|
19
18
|
const connection = new Connection(config.rpcUrl, 'confirmed');
|
|
20
|
-
const [
|
|
21
|
-
tokenBalance(connection, keypair.publicKey,
|
|
22
|
-
|
|
19
|
+
const [usdc, lamports] = await Promise.all([
|
|
20
|
+
tokenBalance(connection, keypair.publicKey, USDC),
|
|
21
|
+
connection.getBalance(keypair.publicKey),
|
|
23
22
|
]);
|
|
23
|
+
const usdcUi = usdc.ui ?? 0;
|
|
24
24
|
console.log(`wallet : ${keypair.publicKey.toBase58()}`);
|
|
25
|
-
console.log(`
|
|
26
|
-
console.log(`
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
console.log(`USDC : ${usdcUi}`);
|
|
26
|
+
console.log(`SOL : ${lamports / 1e9}`);
|
|
27
|
+
console.log(`value : ≈ $${usdcUi.toFixed(2)}`);
|
|
28
|
+
if (!usdc.raw) {
|
|
29
|
+
console.log(`fund : send a few cents of USDC to ${keypair.publicKey.toBase58()}`);
|
|
29
30
|
}
|
|
30
31
|
}
|
package/lib/mcp.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
4
|
import { z } from 'zod';
|
|
@@ -5,8 +6,8 @@ import { config } from './config.js';
|
|
|
5
6
|
import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
|
|
6
7
|
import { tokenBalance } from './x402.js';
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
-
const
|
|
9
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
10
|
+
const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
10
11
|
const DEFAULT_MODEL = process.env.OPENZOO_DEMO_MODEL || 'nvidia/nemotron-3.5-lightning';
|
|
11
12
|
|
|
12
13
|
function text(obj) {
|
|
@@ -16,7 +17,7 @@ function text(obj) {
|
|
|
16
17
|
/** `npx openzoo mcp` — stdio MCP server sharing the proxy's wallet + payment core. */
|
|
17
18
|
export async function startMcp() {
|
|
18
19
|
const client = new PayClient();
|
|
19
|
-
const server = new McpServer({ name: 'openzoo', version:
|
|
20
|
+
const server = new McpServer({ name: 'openzoo', version: pkg.version });
|
|
20
21
|
|
|
21
22
|
server.registerTool('zoo_ask', {
|
|
22
23
|
description:
|
|
@@ -72,14 +73,14 @@ export async function startMcp() {
|
|
|
72
73
|
description: 'The local burner wallet: funding address, balances, and this session\'s payment receipts.',
|
|
73
74
|
inputSchema: {},
|
|
74
75
|
}, async () => {
|
|
75
|
-
const [
|
|
76
|
-
tokenBalance(client.connection, client.keypair.publicKey,
|
|
77
|
-
|
|
76
|
+
const [usdc, lamports] = await Promise.all([
|
|
77
|
+
tokenBalance(client.connection, client.keypair.publicKey, USDC),
|
|
78
|
+
client.connection.getBalance(client.keypair.publicKey),
|
|
78
79
|
]);
|
|
79
80
|
return text({
|
|
80
81
|
solanaAddress: client.address,
|
|
81
|
-
fundWith:
|
|
82
|
-
balances: {
|
|
82
|
+
fundWith: 'USDC — send a few cents of USDC to this address',
|
|
83
|
+
balances: { USDC: usdc.ui ?? 0, SOL: lamports / 1e9, usdValue: usdc.ui ?? 0 },
|
|
83
84
|
receipts: client.receipts.map((r) => ({ at: r.at, line: r.line })),
|
|
84
85
|
});
|
|
85
86
|
});
|
package/lib/pay.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Connection } from '@solana/web3.js';
|
|
1
|
+
import { Connection, PublicKey } from '@solana/web3.js';
|
|
2
|
+
import { getAssociatedTokenAddressSync } from '@solana/spl-token';
|
|
2
3
|
import { config } from './config.js';
|
|
3
4
|
import { loadOrCreateWallet } from './wallet.js';
|
|
4
5
|
import {
|
|
@@ -6,6 +7,9 @@ import {
|
|
|
6
7
|
receiptLine, decodeSettleHeader,
|
|
7
8
|
} from './x402.js';
|
|
8
9
|
import { buildEvmPayment } from './evm.js';
|
|
10
|
+
import {
|
|
11
|
+
resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
|
|
12
|
+
} from './wrap.js';
|
|
9
13
|
|
|
10
14
|
export class QuoteTooHighError extends Error {
|
|
11
15
|
constructor(billedUsd, quote) {
|
|
@@ -16,10 +20,13 @@ export class QuoteTooHighError extends Error {
|
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export class UnderfundedError extends Error {
|
|
19
|
-
constructor(accept,
|
|
20
|
-
|
|
23
|
+
constructor(accept, usdcUi, address) {
|
|
24
|
+
const usd = Number(accept?.extra?.billedUsd);
|
|
25
|
+
const needs = Number.isFinite(usd) ? `this call needs ≈$${usd.toFixed(6)}` : 'this call needs more than the wallet holds';
|
|
26
|
+
const holds = usdcUi != null ? ` — the wallet holds $${Number(usdcUi).toFixed(2)} USDC` : '';
|
|
27
|
+
super(`openzoo wallet underfunded: ${needs}${holds}. Send a few cents of USDC to ${address}.`);
|
|
21
28
|
this.accept = accept;
|
|
22
|
-
this.
|
|
29
|
+
this.usdcUi = usdcUi;
|
|
23
30
|
this.address = address;
|
|
24
31
|
}
|
|
25
32
|
}
|
|
@@ -43,12 +50,18 @@ export class PayClient {
|
|
|
43
50
|
|
|
44
51
|
get address() { return this.keypair.publicKey.toBase58(); }
|
|
45
52
|
|
|
46
|
-
async buildPaymentFor(accept) {
|
|
53
|
+
async buildPaymentFor(accept, onStage) {
|
|
47
54
|
const rail = railOf(accept);
|
|
48
55
|
if (rail === 'solana') {
|
|
56
|
+
const need = BigInt(accept.maxAmountRequired);
|
|
49
57
|
const bal = await tokenBalance(this.connection, this.keypair.publicKey, accept.asset);
|
|
50
|
-
if (bal.raw <
|
|
51
|
-
|
|
58
|
+
if (bal.raw < need) {
|
|
59
|
+
const topUp = await this.topUpQuotedAsset(accept, need, onStage);
|
|
60
|
+
if (topUp.preInstructions) {
|
|
61
|
+
return buildPaymentOnline(this.connection, this.keypair, accept, {
|
|
62
|
+
preInstructions: topUp.preInstructions,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
52
65
|
}
|
|
53
66
|
return buildPaymentOnline(this.connection, this.keypair, accept);
|
|
54
67
|
}
|
|
@@ -58,12 +71,65 @@ export class PayClient {
|
|
|
58
71
|
throw new Error(`no payment builder for rail ${rail} (network ${accept.network})`);
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Internal: make sure the wallet holds enough of the exact asset the 402
|
|
76
|
+
* quotes, by converting the user's plain USDC (or whatever underlying the
|
|
77
|
+
* pool takes) at payment time. Users never see or hold the quoted asset.
|
|
78
|
+
*
|
|
79
|
+
* Returns {} when the balance now covers the quote, or
|
|
80
|
+
* { preInstructions } when the wallet cannot pay network fees itself — the
|
|
81
|
+
* conversion then rides inside the gateway-sponsored payment transaction
|
|
82
|
+
* (the 402's feePayer covers fees and any account rent).
|
|
83
|
+
* Throws UnderfundedError when the underlying balance cannot cover it.
|
|
84
|
+
*/
|
|
85
|
+
async topUpQuotedAsset(accept, need, onStage) {
|
|
86
|
+
const owner = this.keypair.publicKey;
|
|
87
|
+
const pool = await resolvePool(this.connection, accept.asset).catch(() => null);
|
|
88
|
+
if (!pool) throw new UnderfundedError(accept, null, this.address);
|
|
89
|
+
|
|
90
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
91
|
+
const bal = await tokenBalance(this.connection, owner, accept.asset);
|
|
92
|
+
const short = need - bal.raw;
|
|
93
|
+
if (short <= 0n) return {};
|
|
94
|
+
|
|
95
|
+
const { reserves, supply } = await poolState(this.connection, pool);
|
|
96
|
+
const deposit = depositForShares(short, reserves, supply);
|
|
97
|
+
const underlyingBal = await tokenBalance(this.connection, owner, pool.underlying.toBase58());
|
|
98
|
+
if (underlyingBal.raw < deposit) {
|
|
99
|
+
throw new UnderfundedError(accept, underlyingBal.ui, this.address);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onStage?.('funding');
|
|
103
|
+
const wrappedAta = getAssociatedTokenAddressSync(pool.wrapped, owner, false, pool.wrappedProgram);
|
|
104
|
+
const [lamports, ataInfo] = await Promise.all([
|
|
105
|
+
this.connection.getBalance(owner),
|
|
106
|
+
this.connection.getAccountInfo(wrappedAta),
|
|
107
|
+
]);
|
|
108
|
+
const lamportsNeeded = 10000 + (ataInfo ? 0 : 2400000); // tx fee + possible ATA rent
|
|
109
|
+
if (lamports < lamportsNeeded && accept.extra?.feePayer) {
|
|
110
|
+
// No SOL for a standalone conversion — bundle it into the payment tx.
|
|
111
|
+
const rentPayer = new PublicKey(accept.extra.feePayer);
|
|
112
|
+
return {
|
|
113
|
+
preInstructions: buildWrapInstructions({ pool, owner, depositRaw: deposit, rentPayer }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const sig = await sendWrap(this.connection, this.keypair, pool, deposit);
|
|
117
|
+
this.lastTopUpSig = sig; // internal breadcrumb, never printed to users
|
|
118
|
+
if (process.env.OPENZOO_DEBUG) console.error(`[openzoo debug] top-up tx ${sig}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const finalBal = await tokenBalance(this.connection, owner, accept.asset);
|
|
122
|
+
if (finalBal.raw < need) throw new UnderfundedError(accept, null, this.address);
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
|
|
61
126
|
/**
|
|
62
127
|
* fetch that transparently pays a 402 once.
|
|
63
128
|
* Returns { response, paid, accept?, settle?, receipt? }.
|
|
64
129
|
* Throws QuoteTooHighError / UnderfundedError before any value moves.
|
|
65
130
|
*/
|
|
66
|
-
async fetch(url, init = {}) {
|
|
131
|
+
async fetch(url, init = {}, { onStage } = {}) {
|
|
132
|
+
onStage?.('request');
|
|
67
133
|
const first = await fetch(url, init);
|
|
68
134
|
if (first.status !== 402) return { response: first, paid: false };
|
|
69
135
|
|
|
@@ -74,7 +140,9 @@ export class PayClient {
|
|
|
74
140
|
throw new QuoteTooHighError(billedUsd, quote);
|
|
75
141
|
}
|
|
76
142
|
|
|
77
|
-
|
|
143
|
+
onStage?.('quoted');
|
|
144
|
+
const payment = await this.buildPaymentFor(accept, onStage);
|
|
145
|
+
onStage?.('paying');
|
|
78
146
|
const response = await fetch(url, {
|
|
79
147
|
...init,
|
|
80
148
|
headers: { ...(init.headers || {}), 'X-PAYMENT': payment.header },
|
|
@@ -101,12 +169,12 @@ export class PayClient {
|
|
|
101
169
|
}
|
|
102
170
|
|
|
103
171
|
/** POST a chat completion, paying as needed. Returns { data, receipt }. */
|
|
104
|
-
async chat(bodyObj) {
|
|
172
|
+
async chat(bodyObj, { onStage } = {}) {
|
|
105
173
|
const { response, receipt } = await this.fetch(`${config.apiBase}/v1/chat/completions`, {
|
|
106
174
|
method: 'POST',
|
|
107
175
|
headers: { 'content-type': 'application/json' },
|
|
108
176
|
body: JSON.stringify(bodyObj),
|
|
109
|
-
});
|
|
177
|
+
}, { onStage });
|
|
110
178
|
if (!response.ok) {
|
|
111
179
|
const text = (await response.text()).slice(0, 500);
|
|
112
180
|
throw new Error(`zoo returned HTTP ${response.status}: ${text}`);
|
package/lib/proxy.js
CHANGED
|
@@ -93,12 +93,11 @@ export async function startProxy({ silent = false } = {}) {
|
|
|
93
93
|
if (client.walletCreated) console.log(`new burner wallet created at ${client.walletPath} (chmod 600)`);
|
|
94
94
|
console.log(`wallet (fund me): ${client.address}`);
|
|
95
95
|
try {
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
console.log(`balance: ${bal.ui ?? 0} yUSDCx (mint ${yusdcx})`);
|
|
96
|
+
const usdc = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
97
|
+
const bal = await tokenBalance(client.connection, client.keypair.publicKey, usdc);
|
|
98
|
+
console.log(`balance: ${bal.ui ?? 0} USDC (≈ $${(bal.ui ?? 0).toFixed(2)})`);
|
|
100
99
|
if (!bal.raw) {
|
|
101
|
-
console.log('fund it
|
|
100
|
+
console.log('fund it: send a few cents of USDC to the address above — a few cents goes a long way.');
|
|
102
101
|
}
|
|
103
102
|
} catch { /* RPC hiccup: balance is advisory */ }
|
|
104
103
|
console.log('');
|
package/lib/spinner.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny TTY-aware progress line for long waits (big-body upload, zoo pricing,
|
|
3
|
+
* payment settle). No deps: setInterval + \r rewrite on a TTY, plain periodic
|
|
4
|
+
* status lines when piped.
|
|
5
|
+
*
|
|
6
|
+
* const spin = startSpinner('uploading 3.8MB body');
|
|
7
|
+
* spin.update('zoo pricing 965k tokens');
|
|
8
|
+
* spin.stop(); // clears the line; caller prints the result
|
|
9
|
+
*/
|
|
10
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
11
|
+
|
|
12
|
+
export function startSpinner(initialLabel, { stream = process.stdout } = {}) {
|
|
13
|
+
const t0 = Date.now();
|
|
14
|
+
let label = initialLabel;
|
|
15
|
+
let frame = 0;
|
|
16
|
+
let stopped = false;
|
|
17
|
+
const secs = () => Math.round((Date.now() - t0) / 1000);
|
|
18
|
+
|
|
19
|
+
let timer;
|
|
20
|
+
if (stream.isTTY) {
|
|
21
|
+
timer = setInterval(() => {
|
|
22
|
+
frame = (frame + 1) % FRAMES.length;
|
|
23
|
+
stream.write(`\r\x1b[2K${FRAMES[frame]} ${label}… ${secs()}s`);
|
|
24
|
+
}, 100);
|
|
25
|
+
} else {
|
|
26
|
+
let lastPrinted = -5;
|
|
27
|
+
timer = setInterval(() => {
|
|
28
|
+
if (secs() - lastPrinted >= 5) {
|
|
29
|
+
lastPrinted = secs();
|
|
30
|
+
stream.write(`${label}… ${lastPrinted}s\n`);
|
|
31
|
+
}
|
|
32
|
+
}, 1000);
|
|
33
|
+
}
|
|
34
|
+
timer.unref?.();
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
update(newLabel) { label = newLabel; },
|
|
38
|
+
elapsed: secs,
|
|
39
|
+
stop() {
|
|
40
|
+
if (stopped) return;
|
|
41
|
+
stopped = true;
|
|
42
|
+
clearInterval(timer);
|
|
43
|
+
if (stream.isTTY) stream.write('\r\x1b[2K');
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* POST a large JSON body with an upload-completion callback, so callers can
|
|
50
|
+
* flip a spinner label from "uploading" to "server working". Tries a streamed
|
|
51
|
+
* (chunked) upload — the pull() callback tells us when the socket drained the
|
|
52
|
+
* body — and falls back to a plain buffered POST if the runtime or server
|
|
53
|
+
* doesn't take streamed request bodies.
|
|
54
|
+
*/
|
|
55
|
+
export async function postWithUploadSignal(url, bodyString, { headers = {}, onUploaded } = {}) {
|
|
56
|
+
const buf = Buffer.from(bodyString);
|
|
57
|
+
if (typeof ReadableStream === 'function' && buf.length > 262144) {
|
|
58
|
+
try {
|
|
59
|
+
let off = 0;
|
|
60
|
+
const stream = new ReadableStream({
|
|
61
|
+
pull(c) {
|
|
62
|
+
if (off >= buf.length) { c.close(); onUploaded?.(); return; }
|
|
63
|
+
const end = Math.min(off + 65536, buf.length);
|
|
64
|
+
c.enqueue(buf.subarray(off, end));
|
|
65
|
+
off = end;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const r = await fetch(url, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
71
|
+
body: stream,
|
|
72
|
+
duplex: 'half',
|
|
73
|
+
});
|
|
74
|
+
// 411/413 = server refused the chunked upload; retry buffered below.
|
|
75
|
+
if (r.status !== 411 && r.status !== 413) return r;
|
|
76
|
+
} catch { /* runtime without duplex streams — fall through */ }
|
|
77
|
+
}
|
|
78
|
+
const r = await fetch(url, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
81
|
+
body: buf,
|
|
82
|
+
});
|
|
83
|
+
onUploaded?.();
|
|
84
|
+
return r;
|
|
85
|
+
}
|
package/lib/wrap.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal funding plumbing for Solana 402 rails.
|
|
3
|
+
*
|
|
4
|
+
* The zoo's Solana quotes settle in a NAV-wrapped Token-2022 mint issued by
|
|
5
|
+
* the wrap-nav program (FrSER…). Users only ever hold and send plain USDC —
|
|
6
|
+
* this module converts the exact amount needed, at payment time, inside the
|
|
7
|
+
* shim. Nothing here is user-facing; never surface wrapped tickers or mints
|
|
8
|
+
* in messages that reach the user.
|
|
9
|
+
*
|
|
10
|
+
* Verified against the deployed program's own e2e (solana-token-wrap/e2e/e2e.mjs)
|
|
11
|
+
* and live mainnet wrap transactions:
|
|
12
|
+
* wrap ix: data = [1][amount u64 LE][authority bump]
|
|
13
|
+
* keys = [escrow(w), wrappedMint(w), userWrappedAta(w),
|
|
14
|
+
* authorityPDA, wrappedTokenProgram]
|
|
15
|
+
* shares minted = floor(amount * supply / reserves), reserves read at ix
|
|
16
|
+
* execution — so the wrap ix is placed BEFORE the deposit TransferChecked.
|
|
17
|
+
* First deposit is 1:1 minus MINIMUM_LIQUIDITY (1000) locked forever.
|
|
18
|
+
* authority = PDA(['mint_authority', wrappedMint]); escrow = ATA(underlying,
|
|
19
|
+
* authority); registry = PDA(['backpointer', wrappedMint]) when present.
|
|
20
|
+
*/
|
|
21
|
+
import { PublicKey, Transaction, TransactionInstruction, sendAndConfirmTransaction } from '@solana/web3.js';
|
|
22
|
+
import {
|
|
23
|
+
TOKEN_PROGRAM_ID,
|
|
24
|
+
TOKEN_2022_PROGRAM_ID,
|
|
25
|
+
createAssociatedTokenAccountIdempotentInstruction,
|
|
26
|
+
createTransferCheckedInstruction,
|
|
27
|
+
getAssociatedTokenAddressSync,
|
|
28
|
+
unpackAccount,
|
|
29
|
+
unpackMint,
|
|
30
|
+
} from '@solana/spl-token';
|
|
31
|
+
|
|
32
|
+
export const WRAP_PROGRAM_ID = new PublicKey('FrSERTNCPvTtaDS9AvQp9u1nYGzXDb3kC9MdL8Xxn2NE');
|
|
33
|
+
const AUTHORITY_SEED = Buffer.from('mint_authority');
|
|
34
|
+
const BACKPOINTER_SEED = Buffer.from('backpointer');
|
|
35
|
+
export const MINIMUM_LIQUIDITY = 1000n;
|
|
36
|
+
|
|
37
|
+
// Machine-readable per-asset acquire directory published by the facilitator.
|
|
38
|
+
// Consulted first so newly listed twins work with zero code changes; on-chain
|
|
39
|
+
// derivation below is the fallback when the endpoint is unreachable.
|
|
40
|
+
const SUPPORTED_URL = process.env.OPENZOO_SUPPORTED_URL || 'https://x402.accrue.fund/supported';
|
|
41
|
+
let directoryCache = { at: 0, kinds: null };
|
|
42
|
+
|
|
43
|
+
async function acquireDirectory() {
|
|
44
|
+
if (directoryCache.kinds && Date.now() - directoryCache.at < 300000) return directoryCache.kinds;
|
|
45
|
+
try {
|
|
46
|
+
const r = await fetch(SUPPORTED_URL, { signal: AbortSignal.timeout(8000) });
|
|
47
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
48
|
+
const body = await r.json();
|
|
49
|
+
if (Array.isArray(body?.kinds)) directoryCache = { at: Date.now(), kinds: body.kinds };
|
|
50
|
+
} catch { /* endpoint down — fall back to chain derivation */ }
|
|
51
|
+
return directoryCache.kinds;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** /supported entry for a quoted Solana mint, or null. */
|
|
55
|
+
async function directoryEntryFor(wrappedMintStr) {
|
|
56
|
+
const kinds = await acquireDirectory();
|
|
57
|
+
if (!kinds) return null;
|
|
58
|
+
const row = kinds.find((k) => k?.network?.startsWith?.('solana:') && k?.extra?.asset === wrappedMintStr);
|
|
59
|
+
const acq = row?.extra?.acquire;
|
|
60
|
+
return acq?.method === 'spl-token-wrap' && acq.underlying?.address && acq.escrow ? acq : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function u64le(n) { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; }
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the pool behind a quoted mint. Returns null when the mint is not a
|
|
67
|
+
* wrap-nav twin (then there is nothing this module can do for it).
|
|
68
|
+
* Generic over whatever mint the 402 quotes — nothing hardcoded per twin.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolvePool(connection, wrappedMintStr) {
|
|
71
|
+
const wrapped = new PublicKey(wrappedMintStr);
|
|
72
|
+
const info = await connection.getAccountInfo(wrapped);
|
|
73
|
+
if (!info) return null;
|
|
74
|
+
const wrappedProgram = info.owner;
|
|
75
|
+
let mint;
|
|
76
|
+
try { mint = unpackMint(wrapped, info, wrappedProgram); } catch { return null; }
|
|
77
|
+
|
|
78
|
+
// Preferred source: the facilitator's own /supported directory.
|
|
79
|
+
const acq = await directoryEntryFor(wrappedMintStr);
|
|
80
|
+
if (acq) {
|
|
81
|
+
const programId = new PublicKey(acq.program || WRAP_PROGRAM_ID);
|
|
82
|
+
const authority = new PublicKey(acq.mintAuthority);
|
|
83
|
+
let bump = acq.authorityBump;
|
|
84
|
+
if (bump == null) {
|
|
85
|
+
const [derived, derivedBump] = PublicKey.findProgramAddressSync(
|
|
86
|
+
[AUTHORITY_SEED, wrapped.toBuffer()], programId,
|
|
87
|
+
);
|
|
88
|
+
if (derived.equals(authority)) bump = derivedBump;
|
|
89
|
+
}
|
|
90
|
+
if (bump != null && mint.mintAuthority?.equals(authority)) {
|
|
91
|
+
return {
|
|
92
|
+
wrapped,
|
|
93
|
+
wrappedProgram,
|
|
94
|
+
programId,
|
|
95
|
+
authority,
|
|
96
|
+
bump,
|
|
97
|
+
escrow: new PublicKey(acq.escrow),
|
|
98
|
+
underlying: new PublicKey(acq.underlying.address),
|
|
99
|
+
underlyingProgram: new PublicKey(acq.underlying.tokenProgram || TOKEN_PROGRAM_ID),
|
|
100
|
+
underlyingDecimals: acq.underlying.decimals ?? 6,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Fallback: derive everything from chain state.
|
|
106
|
+
const [authority, bump] = PublicKey.findProgramAddressSync(
|
|
107
|
+
[AUTHORITY_SEED, wrapped.toBuffer()], WRAP_PROGRAM_ID,
|
|
108
|
+
);
|
|
109
|
+
if (!mint.mintAuthority || !mint.mintAuthority.equals(authority)) return null;
|
|
110
|
+
|
|
111
|
+
// Prefer the on-chain backpointer registry; fall back to the authority's
|
|
112
|
+
// token accounts (older pools were created before registration existed).
|
|
113
|
+
let underlying; let escrow; let underlyingProgram;
|
|
114
|
+
const [backpointer] = PublicKey.findProgramAddressSync(
|
|
115
|
+
[BACKPOINTER_SEED, wrapped.toBuffer()], WRAP_PROGRAM_ID,
|
|
116
|
+
);
|
|
117
|
+
const bp = await connection.getAccountInfo(backpointer);
|
|
118
|
+
if (bp && bp.owner.equals(WRAP_PROGRAM_ID) && bp.data.length >= 96) {
|
|
119
|
+
underlying = new PublicKey(bp.data.subarray(0, 32));
|
|
120
|
+
escrow = new PublicKey(bp.data.subarray(32, 64));
|
|
121
|
+
underlyingProgram = new PublicKey(bp.data.subarray(64, 96));
|
|
122
|
+
} else {
|
|
123
|
+
for (const programId of [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]) {
|
|
124
|
+
const res = await connection.getTokenAccountsByOwner(authority, { programId });
|
|
125
|
+
if (res.value.length) {
|
|
126
|
+
const first = res.value[0];
|
|
127
|
+
const acc = unpackAccount(first.pubkey, first.account, programId);
|
|
128
|
+
escrow = first.pubkey;
|
|
129
|
+
underlying = acc.mint;
|
|
130
|
+
underlyingProgram = programId;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!escrow) return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const uInfo = await connection.getAccountInfo(underlying);
|
|
138
|
+
if (!uInfo) return null;
|
|
139
|
+
const uMint = unpackMint(underlying, uInfo, underlyingProgram);
|
|
140
|
+
return {
|
|
141
|
+
wrapped,
|
|
142
|
+
wrappedProgram,
|
|
143
|
+
programId: WRAP_PROGRAM_ID,
|
|
144
|
+
authority,
|
|
145
|
+
bump,
|
|
146
|
+
escrow,
|
|
147
|
+
underlying,
|
|
148
|
+
underlyingProgram,
|
|
149
|
+
underlyingDecimals: uMint.decimals,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Underlying deposit required so that floor(deposit * supply / reserves)
|
|
155
|
+
* covers `sharesNeeded`. A small margin absorbs NAV drift between the read
|
|
156
|
+
* and the landing slot (donations/burns only ever reduce shares-per-asset).
|
|
157
|
+
*/
|
|
158
|
+
export function depositForShares(sharesNeeded, reserves, supply) {
|
|
159
|
+
if (supply === 0n || reserves === 0n) return sharesNeeded + MINIMUM_LIQUIDITY;
|
|
160
|
+
const exact = (sharesNeeded * reserves + supply - 1n) / supply; // ceil
|
|
161
|
+
return exact + exact / 200n + 2n; // +0.5% + 2 raw units of drift margin
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Current pool state: escrow reserves and wrapped supply, both raw bigint. */
|
|
165
|
+
export async function poolState(connection, pool) {
|
|
166
|
+
const [esc, sup] = await Promise.all([
|
|
167
|
+
connection.getTokenAccountBalance(pool.escrow).then((r) => BigInt(r.value.amount)).catch(() => 0n),
|
|
168
|
+
connection.getTokenSupply(pool.wrapped).then((r) => BigInt(r.value.amount)),
|
|
169
|
+
]);
|
|
170
|
+
return { reserves: esc, supply: sup };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The three instructions of a conversion, in the mainnet-proven order:
|
|
175
|
+
* ensure the wrapped ATA, mint shares (program reads pre-deposit reserves),
|
|
176
|
+
* then move the deposit into escrow. `rentPayer` funds ATA creation (defaults
|
|
177
|
+
* to the owner; the gateway feePayer when riding inside a payment tx).
|
|
178
|
+
*/
|
|
179
|
+
export function buildWrapInstructions({ pool, owner, depositRaw, rentPayer = owner }) {
|
|
180
|
+
const userWrapped = getAssociatedTokenAddressSync(pool.wrapped, owner, false, pool.wrappedProgram);
|
|
181
|
+
const userUnderlying = getAssociatedTokenAddressSync(pool.underlying, owner, false, pool.underlyingProgram);
|
|
182
|
+
const wrapIx = new TransactionInstruction({
|
|
183
|
+
programId: pool.programId || WRAP_PROGRAM_ID,
|
|
184
|
+
keys: [
|
|
185
|
+
{ pubkey: pool.escrow, isSigner: false, isWritable: true },
|
|
186
|
+
{ pubkey: pool.wrapped, isSigner: false, isWritable: true },
|
|
187
|
+
{ pubkey: userWrapped, isSigner: false, isWritable: true },
|
|
188
|
+
{ pubkey: pool.authority, isSigner: false, isWritable: false },
|
|
189
|
+
{ pubkey: pool.wrappedProgram, isSigner: false, isWritable: false },
|
|
190
|
+
],
|
|
191
|
+
data: Buffer.concat([Buffer.from([1]), u64le(depositRaw), Buffer.from([pool.bump])]),
|
|
192
|
+
});
|
|
193
|
+
return [
|
|
194
|
+
createAssociatedTokenAccountIdempotentInstruction(rentPayer, userWrapped, owner, pool.wrapped, pool.wrappedProgram),
|
|
195
|
+
wrapIx,
|
|
196
|
+
createTransferCheckedInstruction(
|
|
197
|
+
userUnderlying, pool.underlying, pool.escrow, owner,
|
|
198
|
+
depositRaw, pool.underlyingDecimals, [], pool.underlyingProgram,
|
|
199
|
+
),
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Send a self-paid conversion tx and wait for confirmation. Returns the signature. */
|
|
204
|
+
export async function sendWrap(connection, keypair, pool, depositRaw) {
|
|
205
|
+
const ixs = buildWrapInstructions({ pool, owner: keypair.publicKey, depositRaw });
|
|
206
|
+
const tx = new Transaction().add(...ixs);
|
|
207
|
+
return sendAndConfirmTransaction(connection, tx, [keypair], { commitment: 'confirmed' });
|
|
208
|
+
}
|
package/lib/x402.js
CHANGED
|
@@ -101,7 +101,7 @@ export async function getMintInfo(connection, mintStr) {
|
|
|
101
101
|
* Build the partially-signed payment transaction + X-PAYMENT header value.
|
|
102
102
|
* Pure given its inputs (no network) so it can be unit-tested offline.
|
|
103
103
|
*/
|
|
104
|
-
export function buildPayment({ accept, decimals, programId, recentBlockhash, keypair }) {
|
|
104
|
+
export function buildPayment({ accept, decimals, programId, recentBlockhash, keypair, preInstructions = [] }) {
|
|
105
105
|
const mint = new PublicKey(accept.asset);
|
|
106
106
|
const payTo = new PublicKey(accept.payTo);
|
|
107
107
|
const feePayer = new PublicKey(accept.extra.feePayer);
|
|
@@ -115,6 +115,7 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
|
|
|
115
115
|
);
|
|
116
116
|
|
|
117
117
|
const tx = new Transaction({ feePayer, recentBlockhash });
|
|
118
|
+
for (const pre of preInstructions) tx.add(pre); // internal funding plumbing, if any
|
|
118
119
|
tx.add(ix);
|
|
119
120
|
tx.partialSign(keypair); // owner signs; feePayer slot left empty for the facilitator
|
|
120
121
|
const txBase64 = tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64');
|
|
@@ -138,10 +139,10 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
/** Resolve chain state (decimals, blockhash) and build the header. */
|
|
141
|
-
export async function buildPaymentOnline(connection, keypair, accept) {
|
|
142
|
+
export async function buildPaymentOnline(connection, keypair, accept, { preInstructions = [] } = {}) {
|
|
142
143
|
const { programId, decimals } = await getMintInfo(connection, accept.asset);
|
|
143
144
|
const { blockhash } = await connection.getLatestBlockhash('confirmed');
|
|
144
|
-
return buildPayment({ accept, decimals, programId, recentBlockhash: blockhash, keypair });
|
|
145
|
+
return buildPayment({ accept, decimals, programId, recentBlockhash: blockhash, keypair, preInstructions });
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
/** Token balance of our ATA for a mint. Returns { raw: bigint, ui: number|null }. */
|
|
@@ -159,7 +160,7 @@ export async function tokenBalance(connection, owner, mintStr) {
|
|
|
159
160
|
/** One-line human receipt for a paid call. */
|
|
160
161
|
export function receiptLine(accept, settle) {
|
|
161
162
|
const x = accept.extra || {};
|
|
162
|
-
const usd = x.billedUsd != null ? `$${Number(x.billedUsd).toFixed(6)}` : `${accept.maxAmountRequired} raw
|
|
163
|
+
const usd = x.billedUsd != null ? `$${Number(x.billedUsd).toFixed(6)}` : `${accept.maxAmountRequired} raw units`;
|
|
163
164
|
const saves = x.savesVsDirect != null ? ` (${Number(x.savesVsDirect).toFixed(1)}× cheaper than direct)` : (x.markup != null ? ` (markup ${x.markup}×, short body)` : '');
|
|
164
165
|
const tx = settle?.transaction || settle?.txHash || settle?.signature;
|
|
165
166
|
const rail = railOf(accept);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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 rail live; Base/Robinhood rails experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|