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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.51.0",
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",
@@ -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 (2×.so = ${(soSize * 2).toLocaleString()} B)`, kind: 'programdata', bytes: accountBytesFor('programdata', soSize * 2) });
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 });
@@ -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') { keypair = loadWallet({ keypair: f.keypair }).keypair; log(`warning: this hub signs writes with ${keypair.publicKey.toBase58()} for anyone who can reach it`); }
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
- overflow-checks = false
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
- opt-level = 3
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(', ');
@@ -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
  }
@@ -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 };
@@ -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 the binary so later upgrades can grow.
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 * 2;
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
- Ok((h.try_into().unwrap(), rest))
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::{format, string::String, vec::Vec};
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
- Val::Str(format!("{}?{}", self.req.path, self.req.query))
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
- Val::Str(format!("https://zoo.sol{}", self.req_url().to_js_string()))
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
- format!(
283
- "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
284
- y,
285
- m,
286
- d,
287
- sod / 3600,
288
- (sod % 3600) / 60,
289
- sod % 60,
290
- milli
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
- Val::Str(alloc::format!("SyntaxError: {} at position {}", msg, self.i))
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 s = core::str::from_utf8(&self.b[self.i..self.i + 4]).map_err(|_| self.err("Bad unicode escape"))?;
194
- let v = u32::from_str_radix(s, 16).map_err(|_| self.err("Bad unicode escape"))?;
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
- alloc::format!("{} failed: program error {}", what, code)
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
  }
@@ -22,6 +22,7 @@ extern crate alloc;
22
22
 
23
23
  pub mod assets;
24
24
  pub mod ctx;
25
+ pub mod fmt;
25
26
  pub mod json;
26
27
  pub mod kv;
27
28
  pub mod val;
@@ -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::{format, string::String, vec::Vec};
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, Debug)]
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
- if n.is_nan() {
53
- return String::from("NaN");
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
- return match u64::from_str_radix(h, 16) {
86
- Ok(v) => v as f64,
87
- Err(_) => f64::NAN,
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 * libm::pow(10.0, total as f64)
134
+ mant * pow10(total as u32)
154
135
  } else {
155
- mant / libm::pow(10.0, (-total) as f64)
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(libm::pow(self.to_num(), o.to_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 => Err(Val::Str(format!(
713
- "TypeError: Cannot read properties of {} (reading '{}')",
714
- self.to_js_string(),
715
- name
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
- Val::Str(format!("TypeError: {} is not a function", name))
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
- s.parse::<usize>().ok()
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
- if n.is_nan() {
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.to_uppercase()),
783
- "toLowerCase" => Val::Str(s.to_lowercase()),
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" => libm::pow(a(0), a(1)),
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.push_str(&format!("%{:02X}", b));
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)