openzoo 0.51.0 → 0.51.2
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/package.json +1 -1
- package/transmute/lib/build.js +1 -1
- package/transmute/lib/cli.js +8 -2
- package/transmute/lib/compile/rust.js +6 -2
- package/transmute/lib/deploy.js +1 -1
- package/transmute/lib/hub.js +32 -1
- package/transmute/lib/solana.js +5 -3
- package/transmute/runtime/zoo-host/Cargo.toml +2 -0
- package/transmute/runtime/zoo-host/src/assets.rs +2 -1
- package/transmute/runtime/zoo-host/src/ctx.rs +26 -13
- package/transmute/runtime/zoo-host/src/fmt.rs +188 -0
- package/transmute/runtime/zoo-host/src/json.rs +9 -3
- package/transmute/runtime/zoo-host/src/kv.rs +4 -1
- package/transmute/runtime/zoo-host/src/lib.rs +1 -0
- package/transmute/runtime/zoo-host/src/val.rs +61 -55
- package/transmute/site/public/index.html +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.2",
|
|
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. `openzoo build|deploy|serve|hub` transmute a Vercel-shaped app (Next.js / Vite + /api) into a Pinocchio program + asset accounts on Solana mainnet.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/transmute/lib/build.js
CHANGED
|
@@ -150,7 +150,7 @@ export async function estimateCost({ staticFiles = [], soSize = null, manifestBy
|
|
|
150
150
|
if (upgrade) items.push({ label: 'program upgrade buffer (refunded on success)', kind: 'buffer', bytes: accountBytesFor('buffer', soSize), transient: true });
|
|
151
151
|
else {
|
|
152
152
|
items.push({ label: 'program account', kind: 'program', bytes: accountBytesFor('program', 0) });
|
|
153
|
-
items.push({ label: `program data (
|
|
153
|
+
items.push({ label: `program data (.so = ${soSize.toLocaleString()} B)`, kind: 'programdata', bytes: accountBytesFor('programdata', soSize) });
|
|
154
154
|
}
|
|
155
155
|
} else {
|
|
156
156
|
items.push({ label: 'program (no .so built yet)', kind: 'program', bytes: 0, unknown: true });
|
package/transmute/lib/cli.js
CHANGED
|
@@ -26,7 +26,7 @@ export const USAGE = `openzoo-transmute — Vercel app → Solana program + asse
|
|
|
26
26
|
usage:
|
|
27
27
|
openzoo-transmute build [dir] [--out .zoo-out] [--name <crate>] [--arch v0|v3] [--cluster <c>] [--skip-cargo]
|
|
28
28
|
openzoo-transmute deploy [dir|outDir] [--cluster mainnet|devnet|localnet|<url>] [--keypair <path>] [--yes] [--program <id>]
|
|
29
|
-
[--concurrency 4] [--skip-assets] [--force]
|
|
29
|
+
[--concurrency 4] [--skip-assets] [--force] [--headroom 1.5]
|
|
30
30
|
openzoo-transmute serve <programId> [--cluster <c>] [--port ${DEFAULT_PORT}] [--keypair <path>] [--host 127.0.0.1] [--quiet]
|
|
31
31
|
openzoo-transmute hub [--cluster mainnet] [--port 8080] [--host 0.0.0.0] [--public-url <https://…>]
|
|
32
32
|
hosted explorer for EVERY program on the cluster (/s/<programId>), read-only
|
|
@@ -198,8 +198,14 @@ async function cmdHub(p, f, log) {
|
|
|
198
198
|
// The hosted explorer: every program on the cluster from one public host,
|
|
199
199
|
// read-only by default (a public signer would be a drain). --keypair opts in.
|
|
200
200
|
const cluster = f.cluster || process.env.OPENZOO_CLUSTER || 'mainnet';
|
|
201
|
+
// A signer makes the demo real (visitors can POST). It is bounded: a per-IP
|
|
202
|
+
// rate limit and a daily SOL budget (OPENZOO_HUB_WRITE_BUDGET_SOL), so a
|
|
203
|
+
// public hub can sign without being drained. Keypair from --keypair or the
|
|
204
|
+
// OPENZOO_HUB_KEYPAIR secret (JSON byte array).
|
|
201
205
|
let keypair = null;
|
|
202
|
-
if (typeof f.keypair === 'string')
|
|
206
|
+
if (typeof f.keypair === 'string') keypair = loadWallet({ keypair: f.keypair }).keypair;
|
|
207
|
+
else if (process.env.OPENZOO_HUB_KEYPAIR) { const { Keypair } = await import('@solana/web3.js'); keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(process.env.OPENZOO_HUB_KEYPAIR))); }
|
|
208
|
+
if (keypair) log(`hub signs writes with ${keypair.publicKey.toBase58()} (budget ${process.env.OPENZOO_HUB_WRITE_BUDGET_SOL || '0.05'} SOL/day, ${process.env.OPENZOO_HUB_WRITES_PER_MIN || '3'} writes/min/ip)`);
|
|
203
209
|
const port = f.port != null ? Number(f.port) : Number(process.env.PORT || DEFAULT_HUB_PORT);
|
|
204
210
|
const h = await startHub({ cluster, port, host: f.host || '0.0.0.0', keypair, log, quiet: !!f.quiet, publicUrl: f.publicUrl || process.env.OPENZOO_HUB_URL || null, maxSites: f.maxSites ? Number(f.maxSites) : undefined });
|
|
205
211
|
log(`openzoo hub → ${h.url}/.hub (cluster ${cluster}; sites at ${h.url}/s/<programId>)`);
|
|
@@ -741,10 +741,14 @@ libm = "0.2"
|
|
|
741
741
|
zoo-host = { path = ${JSON.stringify(runtimePath)} }
|
|
742
742
|
|
|
743
743
|
[profile.release]
|
|
744
|
-
|
|
744
|
+
# Size is rent: every KB of program is ~0.007 SOL on mainnet.
|
|
745
|
+
opt-level = "z"
|
|
745
746
|
lto = "fat"
|
|
746
747
|
codegen-units = 1
|
|
747
|
-
|
|
748
|
+
panic = "abort"
|
|
749
|
+
strip = true
|
|
750
|
+
debug = false
|
|
751
|
+
overflow-checks = false
|
|
748
752
|
`;
|
|
749
753
|
const envTable = env.map(([k, v]) => `(${rustStr(k)}, ${rustStr(v)})`).join(', ');
|
|
750
754
|
const routesTable = Array.from({ length: routeCount }, (_, i) => `route_${i}`).join(', ');
|
package/transmute/lib/deploy.js
CHANGED
|
@@ -159,7 +159,7 @@ export async function deploy(o = {}) {
|
|
|
159
159
|
signatures.program = r.signature;
|
|
160
160
|
log(`upgraded ${programId.toBase58()} in ${((Date.now() - t0) / 1000).toFixed(1)}s (${r.signature})`);
|
|
161
161
|
} else {
|
|
162
|
-
const r = await deployProgram(connection, { payer, programKeypair, so, onProgress });
|
|
162
|
+
const r = await deployProgram(connection, { payer, programKeypair, so, onProgress, headroom: o.headroom ? Number(o.headroom) : 1 });
|
|
163
163
|
signatures.program = r.signature;
|
|
164
164
|
log(`deployed ${programId.toBase58()} in ${((Date.now() - t0) / 1000).toFixed(1)}s (${r.signature}); max data len ${r.maxDataLen.toLocaleString()} B`);
|
|
165
165
|
}
|
package/transmute/lib/hub.js
CHANGED
|
@@ -78,12 +78,33 @@ export function makeHub(o = {}) {
|
|
|
78
78
|
log: o.log ?? (() => {}),
|
|
79
79
|
makeSite: o.makeSite ?? null,
|
|
80
80
|
startedAt: Date.now(),
|
|
81
|
-
stats: { requests: 0, sitesLoaded: 0 },
|
|
81
|
+
stats: { requests: 0, sitesLoaded: 0, writes: 0, writesRefused: 0, spentLamports: 0 },
|
|
82
82
|
publicUrl: o.publicUrl || null,
|
|
83
|
+
// write governor: per-IP token bucket + a daily lamport budget for the signer
|
|
84
|
+
writesPerMin: o.writesPerMin ?? Number(process.env.OPENZOO_HUB_WRITES_PER_MIN || 3),
|
|
85
|
+
budgetLamports: Math.round((o.writeBudgetSol ?? Number(process.env.OPENZOO_HUB_WRITE_BUDGET_SOL || 0.05)) * 1e9),
|
|
86
|
+
ipHits: new Map(),
|
|
87
|
+
dayStart: Date.now(),
|
|
83
88
|
};
|
|
84
89
|
return hub;
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
93
|
+
|
|
94
|
+
/** Allow a signed write? Refills per minute per IP; budget resets daily. */
|
|
95
|
+
export function allowWrite(hub, ip, now = Date.now()) {
|
|
96
|
+
if (!hub.keypair) return { ok: false, reason: 'no signer' };
|
|
97
|
+
if (now - hub.dayStart > 86_400_000) { hub.dayStart = now; hub.stats.spentLamports = 0; }
|
|
98
|
+
if (hub.stats.spentLamports >= hub.budgetLamports) return { ok: false, reason: 'daily write budget spent' };
|
|
99
|
+
const key = ip || 'unknown';
|
|
100
|
+
const win = hub.ipHits.get(key) || { t: now, n: 0 };
|
|
101
|
+
if (now - win.t > 60_000) { win.t = now; win.n = 0; }
|
|
102
|
+
if (win.n >= hub.writesPerMin) return { ok: false, reason: `rate limit: ${hub.writesPerMin} writes/min` };
|
|
103
|
+
win.n++; hub.ipHits.set(key, win);
|
|
104
|
+
if (hub.ipHits.size > 10_000) hub.ipHits.clear();
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
async function siteFor(hub, programId) {
|
|
88
109
|
const hit = hub.sites.get(programId);
|
|
89
110
|
if (hit) {
|
|
@@ -215,7 +236,17 @@ export async function handleHub(hub, req) {
|
|
|
215
236
|
if (state.noManifest && (rest === '/' || rest === MANIFEST_PATH)) {
|
|
216
237
|
return json(404, { error: 'no site at this program id', program: target, hint: `no manifest at ${MANIFEST_PATH}; is this an openzoo-transmute deployment on ${hub.cluster}?` }, pin ? { 'set-cookie': setCookie(target) } : {});
|
|
217
238
|
}
|
|
239
|
+
if (!READ_METHODS.includes(method) && hub.keypair) {
|
|
240
|
+
const gate = allowWrite(hub, req.remoteAddress || req.headers?.['fly-client-ip'] || req.headers?.['x-forwarded-for']);
|
|
241
|
+
if (!gate.ok) { hub.stats.writesRefused++; return json(429, { error: 'write refused', reason: gate.reason, hint: 'run `npx openzoo serve <programId>` locally to write with your own wallet' }, { 'x-zoo-site': target }); }
|
|
242
|
+
}
|
|
243
|
+
const before = hub.keypair && !READ_METHODS.includes(method) ? await hub.connection.getBalance(hub.keypair.publicKey).catch(() => null) : null;
|
|
218
244
|
const r = await handleRequest(state, { ...req, method, url: rest + (url.search || '') });
|
|
245
|
+
if (before != null) {
|
|
246
|
+
hub.stats.writes++;
|
|
247
|
+
const after = await hub.connection.getBalance(hub.keypair.publicKey).catch(() => before);
|
|
248
|
+
hub.stats.spentLamports += Math.max(0, before - after);
|
|
249
|
+
}
|
|
219
250
|
const headers = { ...(r.headers || {}), 'x-zoo-site': target };
|
|
220
251
|
if (pin) headers['set-cookie'] = setCookie(target);
|
|
221
252
|
return { ...r, headers };
|
package/transmute/lib/solana.js
CHANGED
|
@@ -102,12 +102,14 @@ async function writeBuffer(connection, payer, authority, so, { onProgress } = {}
|
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* Deploy `so` as a new upgradeable program. Returns {programId, signature}.
|
|
105
|
-
* `maxDataLen` defaults to
|
|
105
|
+
* `maxDataLen` defaults to the binary's exact size (rent is per byte; an
|
|
106
|
+
* upgrade that grows the program redeploys to a new id). Pass `headroom`
|
|
107
|
+
* (a multiplier) or `maxDataLen` to reserve room for in-place upgrades.
|
|
106
108
|
*/
|
|
107
|
-
export async function deployProgram(connection, { payer, authority = payer, programKeypair = Keypair.generate(), so, maxDataLen, onProgress }) {
|
|
109
|
+
export async function deployProgram(connection, { payer, authority = payer, programKeypair = Keypair.generate(), so, maxDataLen, headroom = 1, onProgress }) {
|
|
108
110
|
const buffer = await writeBuffer(connection, payer, authority, so, { onProgress });
|
|
109
111
|
const programLamports = await connection.getMinimumBalanceForRentExemption(36);
|
|
110
|
-
const max = maxDataLen || so.length *
|
|
112
|
+
const max = maxDataLen || Math.ceil(so.length * Math.max(1, headroom));
|
|
111
113
|
const programData = programDataPda(programKeypair.publicKey);
|
|
112
114
|
const sig = await sendTx(connection, [
|
|
113
115
|
SystemProgram.createAccount({ fromPubkey: payer.publicKey, newAccountPubkey: programKeypair.publicKey, lamports: programLamports, space: 36, programId: BPF_LOADER_UPGRADEABLE }),
|
|
@@ -23,6 +23,8 @@ solana-sha256-hasher = { version = "3", features = ["sha2"] }
|
|
|
23
23
|
|
|
24
24
|
[features]
|
|
25
25
|
default = []
|
|
26
|
+
# Transcendental Math.* (log/exp/trig) and non-integer `**`: +7 KB of libm.
|
|
27
|
+
mathx = []
|
|
26
28
|
|
|
27
29
|
[lints.rust]
|
|
28
30
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
|
|
@@ -60,7 +60,8 @@ fn split_hash(data: &[u8]) -> Result<(&[u8; 32], &[u8]), ProgramError> {
|
|
|
60
60
|
return Err(ProgramError::InvalidInstructionData);
|
|
61
61
|
}
|
|
62
62
|
let (h, rest) = data.split_at(32);
|
|
63
|
-
|
|
63
|
+
let h: &[u8; 32] = match h.try_into() { Ok(h) => h, Err(_) => return Err(ProgramError::InvalidInstructionData) };
|
|
64
|
+
Ok((h, rest))
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/// `[32 hash][u32 total_len][u8 ct_len][ct]`; accounts: authority, program
|
|
@@ -3,7 +3,8 @@ use crate::json;
|
|
|
3
3
|
use crate::kv::KvState;
|
|
4
4
|
use crate::val::{parse_query, Val};
|
|
5
5
|
use crate::wire::{Req, Resp};
|
|
6
|
-
use alloc::{
|
|
6
|
+
use alloc::{string::String, vec::Vec};
|
|
7
|
+
use crate::fmt::{push_i64, push_padded};
|
|
7
8
|
use pinocchio::{
|
|
8
9
|
sysvars::{clock::Clock, Sysvar},
|
|
9
10
|
AccountView, Address,
|
|
@@ -54,13 +55,18 @@ impl<'a> Ctx<'a> {
|
|
|
54
55
|
if self.req.query.is_empty() {
|
|
55
56
|
Val::str(&self.req.path)
|
|
56
57
|
} else {
|
|
57
|
-
|
|
58
|
+
let mut u = String::from(self.req.path.as_str());
|
|
59
|
+
u.push('?');
|
|
60
|
+
u.push_str(&self.req.query);
|
|
61
|
+
Val::Str(u)
|
|
58
62
|
}
|
|
59
63
|
}
|
|
60
64
|
/// Full URL for `new URL(request.url)` in app-router handlers. The origin
|
|
61
65
|
/// is synthetic; only path/search matter on chain.
|
|
62
66
|
pub fn req_full_url(&self) -> Val {
|
|
63
|
-
|
|
67
|
+
let mut u = String::from("https://zoo.sol");
|
|
68
|
+
u.push_str(&self.req_url().to_js_string());
|
|
69
|
+
Val::Str(u)
|
|
64
70
|
}
|
|
65
71
|
pub fn req_query(&mut self) -> Val {
|
|
66
72
|
if self.query_cache.is_none() {
|
|
@@ -279,18 +285,25 @@ pub fn iso8601(ms: f64) -> String {
|
|
|
279
285
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
|
280
286
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
281
287
|
let y = if m <= 2 { y + 1 } else { y };
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
)
|
|
288
|
+
let mut out = String::new();
|
|
289
|
+
push_i64(&mut out, y);
|
|
290
|
+
out.push('-');
|
|
291
|
+
push_padded(&mut out, m as u64, 2);
|
|
292
|
+
out.push('-');
|
|
293
|
+
push_padded(&mut out, d as u64, 2);
|
|
294
|
+
out.push('T');
|
|
295
|
+
push_padded(&mut out, (sod / 3600) as u64, 2);
|
|
296
|
+
out.push(':');
|
|
297
|
+
push_padded(&mut out, ((sod % 3600) / 60) as u64, 2);
|
|
298
|
+
out.push(':');
|
|
299
|
+
push_padded(&mut out, (sod % 60) as u64, 2);
|
|
300
|
+
out.push('.');
|
|
301
|
+
push_padded(&mut out, milli as u64, 3);
|
|
302
|
+
out.push('Z');
|
|
303
|
+
out
|
|
292
304
|
}
|
|
293
305
|
|
|
306
|
+
|
|
294
307
|
const B58: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
295
308
|
|
|
296
309
|
pub fn base58(bytes: &[u8]) -> String {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
//! Number and hex formatting without `core::fmt`: the float/integer printers
|
|
2
|
+
//! behind `format!` cost ~50 KB of program (flt2dec dragon/grisu + Formatter),
|
|
3
|
+
//! which at Solana rent rates is a third of a SOL per site. These are the
|
|
4
|
+
//! handful of shapes the runtime actually needs.
|
|
5
|
+
use alloc::string::String;
|
|
6
|
+
|
|
7
|
+
pub fn push_u64(out: &mut String, mut n: u64) {
|
|
8
|
+
if n == 0 {
|
|
9
|
+
out.push('0');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let mut buf = [0u8; 20];
|
|
13
|
+
let mut i = buf.len();
|
|
14
|
+
while n > 0 {
|
|
15
|
+
i -= 1;
|
|
16
|
+
buf[i] = b'0' + (n % 10) as u8;
|
|
17
|
+
n /= 10;
|
|
18
|
+
}
|
|
19
|
+
for &b in &buf[i..] {
|
|
20
|
+
out.push(b as char);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
pub fn push_i64(out: &mut String, n: i64) {
|
|
25
|
+
if n < 0 {
|
|
26
|
+
out.push('-');
|
|
27
|
+
push_u64(out, n.unsigned_abs());
|
|
28
|
+
} else {
|
|
29
|
+
push_u64(out, n as u64);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Zero-padded decimal (for dates: `2026`, `09`, `007`).
|
|
34
|
+
pub fn push_padded(out: &mut String, n: u64, width: usize) {
|
|
35
|
+
let mut tmp = String::new();
|
|
36
|
+
push_u64(&mut tmp, n);
|
|
37
|
+
for _ in tmp.len()..width {
|
|
38
|
+
out.push('0');
|
|
39
|
+
}
|
|
40
|
+
out.push_str(&tmp);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pub fn push_hex2(out: &mut String, b: u8) {
|
|
44
|
+
const H: &[u8; 16] = b"0123456789ABCDEF";
|
|
45
|
+
out.push(H[(b >> 4) as usize] as char);
|
|
46
|
+
out.push(H[(b & 15) as usize] as char);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pub fn u64_string(n: u64) -> String {
|
|
50
|
+
let mut s = String::new();
|
|
51
|
+
push_u64(&mut s, n);
|
|
52
|
+
s
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// 10^k for small k without `libm::pow`.
|
|
56
|
+
pub fn pow10(k: u32) -> f64 {
|
|
57
|
+
let mut v = 1.0;
|
|
58
|
+
for _ in 0..k {
|
|
59
|
+
v *= 10.0;
|
|
60
|
+
}
|
|
61
|
+
v
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// JS `Number.prototype.toString()` for the common cases, hand-rolled:
|
|
65
|
+
/// integers exactly (|n| < 1e21), otherwise up to 15 significant digits with
|
|
66
|
+
/// trailing zeros trimmed (`0.1 + 0.2` prints `0.30000000000000004` in JS and
|
|
67
|
+
/// `0.3` here — the one visible difference), exponent form outside
|
|
68
|
+
/// [1e-6, 1e21) like JS.
|
|
69
|
+
pub fn push_f64(out: &mut String, n: f64) {
|
|
70
|
+
if n.is_nan() {
|
|
71
|
+
out.push_str("NaN");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if n.is_infinite() {
|
|
75
|
+
out.push_str(if n > 0.0 { "Infinity" } else { "-Infinity" });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if n == 0.0 {
|
|
79
|
+
out.push('0');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
let neg = n < 0.0;
|
|
83
|
+
let a = if neg { -n } else { n };
|
|
84
|
+
if neg {
|
|
85
|
+
out.push('-');
|
|
86
|
+
}
|
|
87
|
+
if a == libm::trunc(a) && a < 9.0e18 {
|
|
88
|
+
push_u64(out, a as u64);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Scientific decomposition: a = m × 10^e with 1 <= m < 10.
|
|
92
|
+
let mut e: i32 = 0;
|
|
93
|
+
let mut m = a;
|
|
94
|
+
while m >= 10.0 {
|
|
95
|
+
m /= 10.0;
|
|
96
|
+
e += 1;
|
|
97
|
+
}
|
|
98
|
+
while m < 1.0 {
|
|
99
|
+
m *= 10.0;
|
|
100
|
+
e -= 1;
|
|
101
|
+
}
|
|
102
|
+
// 15 significant digits, rounded.
|
|
103
|
+
let mut digits = [0u8; 17];
|
|
104
|
+
let mut nd = 0;
|
|
105
|
+
let mut scaled = m * 1e14; // 15 digits before the point
|
|
106
|
+
scaled = libm::round(scaled);
|
|
107
|
+
if scaled >= 1e15 {
|
|
108
|
+
scaled /= 10.0;
|
|
109
|
+
e += 1;
|
|
110
|
+
}
|
|
111
|
+
let mut v = scaled as u64;
|
|
112
|
+
let mut tmp = [0u8; 20];
|
|
113
|
+
let mut i = tmp.len();
|
|
114
|
+
while v > 0 {
|
|
115
|
+
i -= 1;
|
|
116
|
+
tmp[i] = (v % 10) as u8;
|
|
117
|
+
v /= 10;
|
|
118
|
+
}
|
|
119
|
+
for &d in &tmp[i..] {
|
|
120
|
+
digits[nd] = d;
|
|
121
|
+
nd += 1;
|
|
122
|
+
}
|
|
123
|
+
while nd > 1 && digits[nd - 1] == 0 {
|
|
124
|
+
nd -= 1;
|
|
125
|
+
}
|
|
126
|
+
if !(-7..21).contains(&e) {
|
|
127
|
+
out.push((b'0' + digits[0]) as char);
|
|
128
|
+
if nd > 1 {
|
|
129
|
+
out.push('.');
|
|
130
|
+
for &d in &digits[1..nd] {
|
|
131
|
+
out.push((b'0' + d) as char);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
out.push('e');
|
|
135
|
+
out.push(if e < 0 { '-' } else { '+' });
|
|
136
|
+
push_u64(out, e.unsigned_abs() as u64);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if e < 0 {
|
|
140
|
+
out.push_str("0.");
|
|
141
|
+
for _ in 0..(-e - 1) {
|
|
142
|
+
out.push('0');
|
|
143
|
+
}
|
|
144
|
+
for &d in &digits[..nd] {
|
|
145
|
+
out.push((b'0' + d) as char);
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let int_len = (e + 1) as usize;
|
|
150
|
+
for k in 0..int_len {
|
|
151
|
+
out.push((b'0' + if k < nd { digits[k] } else { 0 }) as char);
|
|
152
|
+
}
|
|
153
|
+
if nd > int_len {
|
|
154
|
+
out.push('.');
|
|
155
|
+
for &d in &digits[int_len..nd] {
|
|
156
|
+
out.push((b'0' + d) as char);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// `toFixed(digits)`.
|
|
162
|
+
pub fn to_fixed(n: f64, digits: usize) -> String {
|
|
163
|
+
let mut out = String::new();
|
|
164
|
+
if n.is_nan() {
|
|
165
|
+
out.push_str("NaN");
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
let neg = n < 0.0;
|
|
169
|
+
let a = if neg { -n } else { n };
|
|
170
|
+
let m = pow10(digits as u32);
|
|
171
|
+
let scaled = libm::round(a * m) as u64;
|
|
172
|
+
let int = scaled / (m as u64);
|
|
173
|
+
let frac = scaled % (m as u64);
|
|
174
|
+
if neg && scaled != 0 {
|
|
175
|
+
out.push('-');
|
|
176
|
+
}
|
|
177
|
+
push_u64(&mut out, int);
|
|
178
|
+
if digits > 0 {
|
|
179
|
+
out.push('.');
|
|
180
|
+
let mut f = String::new();
|
|
181
|
+
push_u64(&mut f, frac);
|
|
182
|
+
for _ in f.len()..digits {
|
|
183
|
+
out.push('0');
|
|
184
|
+
}
|
|
185
|
+
out.push_str(&f);
|
|
186
|
+
}
|
|
187
|
+
out
|
|
188
|
+
}
|
|
@@ -96,7 +96,11 @@ struct Parser<'a> {
|
|
|
96
96
|
|
|
97
97
|
impl<'a> Parser<'a> {
|
|
98
98
|
fn err(&self, msg: &str) -> Val {
|
|
99
|
-
|
|
99
|
+
let mut m = String::from("SyntaxError: ");
|
|
100
|
+
m.push_str(msg);
|
|
101
|
+
m.push_str(" at position ");
|
|
102
|
+
crate::fmt::push_u64(&mut m, self.i as u64);
|
|
103
|
+
Val::Str(m)
|
|
100
104
|
}
|
|
101
105
|
fn ws(&mut self) {
|
|
102
106
|
while self.i < self.b.len() && matches!(self.b[self.i], b' ' | b'\n' | b'\r' | b'\t') {
|
|
@@ -190,8 +194,10 @@ impl<'a> Parser<'a> {
|
|
|
190
194
|
if self.i + 4 > self.b.len() {
|
|
191
195
|
return Err(self.err("Bad unicode escape"));
|
|
192
196
|
}
|
|
193
|
-
let
|
|
194
|
-
|
|
197
|
+
let mut v: u32 = 0;
|
|
198
|
+
for &c in &self.b[self.i..self.i + 4] {
|
|
199
|
+
v = v * 16 + match (c as char).to_digit(16) { Some(d) => d, None => return Err(self.err("Bad unicode escape")) };
|
|
200
|
+
}
|
|
195
201
|
self.i += 4;
|
|
196
202
|
Ok(v)
|
|
197
203
|
}
|
|
@@ -178,5 +178,8 @@ impl<'a> Ctx<'a> {
|
|
|
178
178
|
|
|
179
179
|
pub fn err_str(what: &str, e: pinocchio::error::ProgramError) -> String {
|
|
180
180
|
let code: u64 = e.into();
|
|
181
|
-
|
|
181
|
+
let mut m = String::from(what);
|
|
182
|
+
m.push_str(" failed: program error ");
|
|
183
|
+
crate::fmt::push_u64(&mut m, code);
|
|
184
|
+
m
|
|
182
185
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
//! A JS-semantics dynamic value. Every expression in a transmuted handler
|
|
2
2
|
//! evaluates to a `Val`; the operators here follow ECMAScript coercion rules
|
|
3
3
|
//! for the subset the transmuter accepts.
|
|
4
|
-
use alloc::{
|
|
4
|
+
use alloc::{string::String, vec::Vec};
|
|
5
|
+
use crate::fmt::{push_f64, push_hex2, pow10};
|
|
5
6
|
use core::cmp::Ordering;
|
|
6
7
|
|
|
7
|
-
#[derive(Clone
|
|
8
|
+
#[derive(Clone)]
|
|
8
9
|
pub enum Val {
|
|
9
10
|
Undef,
|
|
10
11
|
Null,
|
|
@@ -47,32 +48,11 @@ impl From<bool> for Val {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
/// JS Number::toString for the common cases.
|
|
51
|
+
/// JS Number::toString for the common cases (see fmt::push_f64).
|
|
51
52
|
pub fn num_to_string(n: f64) -> String {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if n.is_infinite() {
|
|
56
|
-
return String::from(if n > 0.0 { "Infinity" } else { "-Infinity" });
|
|
57
|
-
}
|
|
58
|
-
if n == 0.0 {
|
|
59
|
-
return String::from("0");
|
|
60
|
-
}
|
|
61
|
-
if n == libm::trunc(n) && libm::fabs(n) < 1e21 {
|
|
62
|
-
return format!("{}", n as i128);
|
|
63
|
-
}
|
|
64
|
-
let a = libm::fabs(n);
|
|
65
|
-
if !(1e-6..1e21).contains(&a) {
|
|
66
|
-
// JS switches to exponent notation here; Rust's `{:e}` prints "1e-7"
|
|
67
|
-
// and JS prints "1e-7" too, so mirror it (JS adds "+" for positive
|
|
68
|
-
// exponents).
|
|
69
|
-
let s = format!("{:e}", n);
|
|
70
|
-
return match s.find('e') {
|
|
71
|
-
Some(i) if !s[i + 1..].starts_with('-') => format!("{}e+{}", &s[..i], &s[i + 1..]),
|
|
72
|
-
_ => s,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
format!("{}", n)
|
|
53
|
+
let mut s = String::new();
|
|
54
|
+
push_f64(&mut s, n);
|
|
55
|
+
s
|
|
76
56
|
}
|
|
77
57
|
|
|
78
58
|
/// JS ToNumber for strings.
|
|
@@ -82,10 +62,11 @@ pub fn str_to_num(s: &str) -> f64 {
|
|
|
82
62
|
return 0.0;
|
|
83
63
|
}
|
|
84
64
|
if let Some(h) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
65
|
+
let mut v: f64 = 0.0;
|
|
66
|
+
for c in h.chars() {
|
|
67
|
+
match c.to_digit(16) { Some(d) => v = v * 16.0 + d as f64, None => return f64::NAN }
|
|
68
|
+
}
|
|
69
|
+
return v;
|
|
89
70
|
}
|
|
90
71
|
match t {
|
|
91
72
|
"Infinity" | "+Infinity" => return f64::INFINITY,
|
|
@@ -150,9 +131,9 @@ pub fn parse_decimal(s: &str) -> Option<f64> {
|
|
|
150
131
|
}
|
|
151
132
|
let total = frac_exp + exp;
|
|
152
133
|
let v = if total >= 0 {
|
|
153
|
-
mant *
|
|
134
|
+
mant * pow10(total as u32)
|
|
154
135
|
} else {
|
|
155
|
-
mant /
|
|
136
|
+
mant / pow10((-total) as u32)
|
|
156
137
|
};
|
|
157
138
|
Some(if neg { -v } else { v })
|
|
158
139
|
}
|
|
@@ -435,7 +416,7 @@ impl Val {
|
|
|
435
416
|
Val::Num(libm::fmod(self.to_num(), o.to_num()))
|
|
436
417
|
}
|
|
437
418
|
pub fn pow(&self, o: &Val) -> Val {
|
|
438
|
-
Val::Num(
|
|
419
|
+
Val::Num(js_pow(self.to_num(), o.to_num()))
|
|
439
420
|
}
|
|
440
421
|
pub fn neg(&self) -> Val {
|
|
441
422
|
Val::Num(-self.to_num())
|
|
@@ -709,17 +690,40 @@ impl Val {
|
|
|
709
690
|
"toString" => Ok(Val::Str(self.to_js_string())),
|
|
710
691
|
_ => Err(type_error(name)),
|
|
711
692
|
},
|
|
712
|
-
Val::Undef | Val::Null =>
|
|
713
|
-
"TypeError: Cannot read properties of
|
|
714
|
-
self.to_js_string()
|
|
715
|
-
|
|
716
|
-
|
|
693
|
+
Val::Undef | Val::Null => {
|
|
694
|
+
let mut m = String::from("TypeError: Cannot read properties of ");
|
|
695
|
+
m.push_str(&self.to_js_string());
|
|
696
|
+
m.push_str(" (reading '");
|
|
697
|
+
m.push_str(name);
|
|
698
|
+
m.push_str("')");
|
|
699
|
+
Err(Val::Str(m))
|
|
700
|
+
}
|
|
717
701
|
}
|
|
718
702
|
}
|
|
719
703
|
}
|
|
720
704
|
|
|
721
705
|
fn type_error(name: &str) -> Val {
|
|
722
|
-
|
|
706
|
+
let mut m = String::from("TypeError: ");
|
|
707
|
+
m.push_str(name);
|
|
708
|
+
m.push_str(" is not a function");
|
|
709
|
+
Val::Str(m)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/// `**` / Math.pow: exact for integer exponents (a loop), libm only with the
|
|
713
|
+
/// `mathx` feature.
|
|
714
|
+
pub fn js_pow(base: f64, exp: f64) -> f64 {
|
|
715
|
+
if exp == libm::trunc(exp) && libm::fabs(exp) <= 64.0 {
|
|
716
|
+
let mut r = 1.0;
|
|
717
|
+
let n = libm::fabs(exp) as u32;
|
|
718
|
+
for _ in 0..n {
|
|
719
|
+
r *= base;
|
|
720
|
+
}
|
|
721
|
+
return if exp < 0.0 { 1.0 / r } else { r };
|
|
722
|
+
}
|
|
723
|
+
#[cfg(feature = "mathx")]
|
|
724
|
+
{ libm::pow(base, exp) }
|
|
725
|
+
#[cfg(not(feature = "mathx"))]
|
|
726
|
+
{ f64::NAN }
|
|
723
727
|
}
|
|
724
728
|
|
|
725
729
|
fn to_i32(n: f64) -> i32 {
|
|
@@ -737,7 +741,9 @@ fn parse_index(s: &str) -> Option<usize> {
|
|
|
737
741
|
if s.len() > 1 && s.starts_with('0') {
|
|
738
742
|
return None;
|
|
739
743
|
}
|
|
740
|
-
|
|
744
|
+
let mut v: usize = 0;
|
|
745
|
+
for b in s.bytes() { v = v * 10 + (b - b'0') as usize; }
|
|
746
|
+
Some(v)
|
|
741
747
|
}
|
|
742
748
|
|
|
743
749
|
fn clamp_index(len: usize, v: &Val) -> usize {
|
|
@@ -763,24 +769,15 @@ fn slice_bounds(len: usize, start: &Val, end: &Val) -> (usize, usize) {
|
|
|
763
769
|
}
|
|
764
770
|
|
|
765
771
|
pub fn to_fixed(n: f64, digits: usize) -> String {
|
|
766
|
-
|
|
767
|
-
return String::from("NaN");
|
|
768
|
-
}
|
|
769
|
-
let m = libm::pow(10.0, digits as f64);
|
|
770
|
-
let r = libm::round(n * m) / m;
|
|
771
|
-
if digits == 0 {
|
|
772
|
-
return format!("{}", r as i128);
|
|
773
|
-
}
|
|
774
|
-
let s = format!("{:.*}", digits, r);
|
|
775
|
-
s
|
|
772
|
+
crate::fmt::to_fixed(n, digits)
|
|
776
773
|
}
|
|
777
774
|
|
|
778
775
|
fn str_method(s: &mut String, name: &str, args: &[Val]) -> Result<Val, Val> {
|
|
779
776
|
let arg = |i: usize| args.get(i).cloned().unwrap_or(Val::Undef);
|
|
780
777
|
let chars: Vec<char> = s.chars().collect();
|
|
781
778
|
Ok(match name {
|
|
782
|
-
"toUpperCase" => Val::Str(s.
|
|
783
|
-
"toLowerCase" => Val::Str(s.
|
|
779
|
+
"toUpperCase" => Val::Str(s.to_ascii_uppercase()),
|
|
780
|
+
"toLowerCase" => Val::Str(s.to_ascii_lowercase()),
|
|
784
781
|
"trim" => Val::Str(String::from(s.trim())),
|
|
785
782
|
"trimStart" => Val::Str(String::from(s.trim_start())),
|
|
786
783
|
"trimEnd" => Val::Str(String::from(s.trim_end())),
|
|
@@ -889,18 +886,26 @@ pub fn math(name: &str, args: &[Val]) -> Result<Val, Val> {
|
|
|
889
886
|
"trunc" => libm::trunc(a(0)),
|
|
890
887
|
"abs" => libm::fabs(a(0)),
|
|
891
888
|
"sqrt" => libm::sqrt(a(0)),
|
|
892
|
-
"pow" =>
|
|
889
|
+
"pow" => js_pow(a(0), a(1)),
|
|
893
890
|
"sign" => {
|
|
894
891
|
let x = a(0);
|
|
895
892
|
if x > 0.0 { 1.0 } else if x < 0.0 { -1.0 } else { x }
|
|
896
893
|
}
|
|
894
|
+
#[cfg(feature = "mathx")]
|
|
897
895
|
"log" => libm::log(a(0)),
|
|
896
|
+
#[cfg(feature = "mathx")]
|
|
898
897
|
"log2" => libm::log2(a(0)),
|
|
898
|
+
#[cfg(feature = "mathx")]
|
|
899
899
|
"log10" => libm::log10(a(0)),
|
|
900
|
+
#[cfg(feature = "mathx")]
|
|
900
901
|
"exp" => libm::exp(a(0)),
|
|
902
|
+
#[cfg(feature = "mathx")]
|
|
901
903
|
"sin" => libm::sin(a(0)),
|
|
904
|
+
#[cfg(feature = "mathx")]
|
|
902
905
|
"cos" => libm::cos(a(0)),
|
|
906
|
+
#[cfg(feature = "mathx")]
|
|
903
907
|
"tan" => libm::tan(a(0)),
|
|
908
|
+
#[cfg(feature = "mathx")]
|
|
904
909
|
"atan2" => libm::atan2(a(0), a(1)),
|
|
905
910
|
"min" => args.iter().map(|v| v.to_num()).fold(f64::INFINITY, f64::min),
|
|
906
911
|
"max" => args.iter().map(|v| v.to_num()).fold(f64::NEG_INFINITY, f64::max),
|
|
@@ -998,7 +1003,8 @@ pub fn url_encode(s: &str, component: bool) -> String {
|
|
|
998
1003
|
if keep {
|
|
999
1004
|
out.push(b as char);
|
|
1000
1005
|
} else {
|
|
1001
|
-
out.
|
|
1006
|
+
out.push('%');
|
|
1007
|
+
push_hex2(&mut out, b);
|
|
1002
1008
|
}
|
|
1003
1009
|
}
|
|
1004
1010
|
out
|
|
@@ -43,6 +43,40 @@ input{padding:.4rem .6rem;font:inherit;border:1px solid #8884;border-radius:.4re
|
|
|
43
43
|
<div class="demo"><b>/api/time</b> — <code>Date.now()</code> is the cluster clock<br><button id="time">GET /api/time</button><pre id="o-time"></pre></div>
|
|
44
44
|
<div class="demo"><b>/api/echo</b> — app-router <code>POST(request)</code> with <code>await request.json()</code>, answers 201<br><button id="echo">POST /api/echo</button><pre id="o-echo"></pre></div>
|
|
45
45
|
|
|
46
|
+
<h2>Capabilities, side by side</h2>
|
|
47
|
+
<p class="muted">Vercel the platform vs openzoo-transmute today. Honest, not flattering.</p>
|
|
48
|
+
<table>
|
|
49
|
+
<tr><th>Capability</th><th>Vercel</th><th>openzoo-transmute (Solana)</th></tr>
|
|
50
|
+
<tr><td>Static hosting</td><td>CDN, any size</td><td>yes: files in accounts, read by any gateway; rent ~7 SOL per MB, so KBs not MBs</td></tr>
|
|
51
|
+
<tr><td>Serverless functions</td><td>Node, Edge, Python, Go, Ruby…</td><td>JS/TS handlers only, compiled to a Rust subset</td></tr>
|
|
52
|
+
<tr><td>Framework support</td><td>Next.js (full: SSR, RSC, ISR), 40+ frameworks</td><td>Next.js <code>pages/api</code> + app-router <code>route.ts</code>, Vite/static + <code>api/</code>; no SSR, no React Server Components, no ISR</td></tr>
|
|
53
|
+
<tr><td>Language coverage</td><td>everything</td><td>expressions, control flow, strings/arrays/objects, JSON, <code>@vercel/kv</code>, <code>Date.now</code>, <code>process.env</code>; no npm packages, regex, classes, generators, closures over mutable state</td></tr>
|
|
54
|
+
<tr><td>Outbound network</td><td><code>fetch</code> anything</td><td>none. A program cannot make HTTP calls</td></tr>
|
|
55
|
+
<tr><td>Databases</td><td>Postgres, KV, Blob, any external DB</td><td>KV on program-derived accounts (10 KB values, public); cross-program reads possible in principle, not exposed</td></tr>
|
|
56
|
+
<tr><td>Secrets</td><td>encrypted env vars</td><td>none. Env is baked into public bytecode</td></tr>
|
|
57
|
+
<tr><td>Auth</td><td>anything (cookies, JWT, OAuth)</td><td>wallet signatures are the identity; no server secret, so no sessions/JWT</td></tr>
|
|
58
|
+
<tr><td>Streaming / SSE / WebSockets</td><td>yes</td><td>no. One request = one transaction, ~8 KB response</td></tr>
|
|
59
|
+
<tr><td>Request size</td><td>4.5 MB</td><td>~900 bytes body</td></tr>
|
|
60
|
+
<tr><td>Execution limits</td><td>10–800 s, up to 4 GB</td><td>200k–1.4M compute units (~1–5 ms of CPU), 256 KB heap</td></tr>
|
|
61
|
+
<tr><td>Randomness / time</td><td>yes</td><td>no <code>Math.random</code> (deterministic); time = slot clock</td></tr>
|
|
62
|
+
<tr><td>Background work (<code>waitUntil</code>, queues, cron)</td><td>yes</td><td>no</td></tr>
|
|
63
|
+
<tr><td>Middleware / edge</td><td>yes</td><td>no (gateway does routing/rewrites/redirects from <code>vercel.json</code>)</td></tr>
|
|
64
|
+
<tr><td>Image optimization</td><td>yes</td><td>no</td></tr>
|
|
65
|
+
<tr><td>Domains / TLS</td><td>built in</td><td>via a gateway host (this one or your own); the chain has no HTTP</td></tr>
|
|
66
|
+
<tr><td>Previews / rollbacks / git integration</td><td>yes</td><td>no. Each deploy is a program (new rent) or an upgrade in place</td></tr>
|
|
67
|
+
<tr><td>Observability</td><td>logs, analytics, tracing</td><td>transaction logs, compute-unit counts, block explorers</td></tr>
|
|
68
|
+
<tr><td>Latency</td><td>~50–300 ms</td><td>reads 300–800 ms, writes 1–3 s</td></tr>
|
|
69
|
+
<tr><td>Read cost</td><td>metered</td><td>free, unlimited, no account needed</td></tr>
|
|
70
|
+
<tr><td>Write cost</td><td>free</td><td>~0.000005 SOL per transaction + rent for new state; a signer is required (your wallet, a bounded hub signer, or x402 pay-per-write once built)</td></tr>
|
|
71
|
+
<tr><td>Deploy cost</td><td>free tier</td><td>rent up front (~1 SOL for a small app today; ~0.05 SOL once the shared runtime lands)</td></tr>
|
|
72
|
+
<tr><td>Toolchain</td><td>node</td><td>node + Rust + <code>cargo-build-sbf</code> (goes away with the shared runtime)</td></tr>
|
|
73
|
+
<tr><td>Who can take it down</td><td>Vercel, DNS, a card on file</td><td>nobody, if the upgrade authority is burned; any RPC + any gateway serves it</td></tr>
|
|
74
|
+
<tr><td>Verifiability</td><td>trust the platform</td><td>the code and every state change are public and replayable</td></tr>
|
|
75
|
+
<tr><td>Composability</td><td>HTTP APIs</td><td>other programs and wallets can call your routes as instructions</td></tr>
|
|
76
|
+
<tr><td>Permanence</td><td>until the bill stops</td><td>until the rent is withdrawn; rent-exempt accounts persist indefinitely</td></tr>
|
|
77
|
+
</table>
|
|
78
|
+
<p>Summary: it is a permanent, permissionless, verifiable host for small stateful APIs and static frontends, not a general web platform. If your app needs the network, secrets, streaming, or heavy compute, it stays on Vercel; if what it needs is to exist forever and be readable by anyone for free, it belongs here.</p>
|
|
79
|
+
|
|
46
80
|
<h2>Put your own app here</h2>
|
|
47
81
|
<pre>npx openzoo inspect . # your app, in Vercel terms + what is eligible
|
|
48
82
|
npx openzoo build . # Rust program + asset plan (needs cargo-build-sbf)
|