openzoo 0.51.2 → 0.51.3

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.
@@ -9,8 +9,9 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { Keypair, PublicKey } from '@solana/web3.js';
12
- import { MANIFEST_PATH } from './wire.js';
12
+ import { MANIFEST_PATH, CODE_PATH } from './wire.js';
13
13
  import { connect, deployProgram, upgradeProgram, getProgramInfo, putAsset, detectSbpfArch, pool, assetRent } from './solana.js';
14
+ import { initSite, getSiteInfo } from './solana.js';
14
15
  import { loadWallet, rpcUrl } from './wallet.js';
15
16
  import { runCargoBuildSbf, estimateCost, formatCostTable, LAMPORTS_PER_SOL, INSTALL_HINT } from './build.js';
16
17
  import { DEFAULT_PORT } from './gateway.js';
@@ -83,6 +84,7 @@ export async function deploy(o = {}) {
83
84
  const buildInfo = readJson(path.join(zooDir, 'build.json'), {});
84
85
  const staticPlan = readJson(path.join(zooDir, 'static-plan.json'), []);
85
86
  const crateDir = path.join(zooDir, 'crate');
87
+ if ((buildInfo.target || manifest.target) === 'shared') return deployShared(o, { log, outDir, zooDir, manifest, buildInfo, staticPlan });
86
88
 
87
89
  const rpc = o.connection?.rpcEndpoint || rpcUrl(o.cluster);
88
90
  const cluster = clusterLabel(o.cluster);
@@ -207,3 +209,149 @@ export async function deploy(o = {}) {
207
209
  export async function assetRentSol(connection, size, contentType = '') {
208
210
  return (await assetRent(connection, size, contentType)) / LAMPORTS_PER_SOL;
209
211
  }
212
+
213
+ // ---------------------------------------------------------------- shared runtime
214
+
215
+ /** Load or create the site keypair: its public key is the site id (looks like a program id, costs nothing). */
216
+ export function siteKeypairFor(zooDir, { create = true } = {}) {
217
+ const p = path.join(zooDir, 'site-keypair.json');
218
+ if (fs.existsSync(p)) return { keypair: Keypair.fromSecretKey(Uint8Array.from(readJson(p))), path: p, created: false };
219
+ if (!create) return null;
220
+ const kp = Keypair.generate();
221
+ fs.writeFileSync(p, JSON.stringify([...kp.secretKey]), { mode: 0o600 });
222
+ return { keypair: kp, path: p, created: true };
223
+ }
224
+
225
+ /**
226
+ * Deploy to the shared runtime: no program, only accounts — the site account,
227
+ * the bytecode module at /.zoo/code.bin, the static assets and the manifest,
228
+ * all PDAs of the runtime program namespaced by the site id.
229
+ */
230
+ async function deployShared(o, { log, outDir, zooDir, manifest, buildInfo, staticPlan }) {
231
+ const runtime = o.runtime || process.env.OPENZOO_VM_PROGRAM || manifest.runtime || buildInfo.runtime;
232
+ if (!runtime) throw new Error('shared target needs the runtime program id: --runtime <id> (or OPENZOO_VM_PROGRAM)');
233
+ const rpc = o.connection?.rpcEndpoint || rpcUrl(o.cluster);
234
+ const cluster = clusterLabel(o.cluster);
235
+ const connection = o.connection || connect(rpc);
236
+ let mainnet = isMainnet(o.cluster, rpc);
237
+ const genesis = await genesisCluster(connection);
238
+ if (genesis === 'mainnet' && !mainnet) { mainnet = true; log(`note: ${rpc.replace(/\?.*$/, '')} serves mainnet (genesis hash); treating it as mainnet`); }
239
+ const wallet = o.wallet || loadWallet({ keypair: o.keypair });
240
+ const payer = wallet.keypair;
241
+ const codePath = buildInfo.codePath && fs.existsSync(buildInfo.codePath) ? buildInfo.codePath : path.join(zooDir, 'code.bin');
242
+ if (!fs.existsSync(codePath)) throw new Error(`no bytecode module at ${codePath}; run build --target shared first`);
243
+ const code = fs.readFileSync(codePath);
244
+ const siteKp = o.site ? null : siteKeypairFor(zooDir);
245
+ const site = o.site ? new PublicKey(o.site) : siteKp.keypair.publicKey;
246
+ const runtimeInfo = await getProgramInfo(connection, runtime);
247
+ if (!runtimeInfo.exists) throw new Error(`runtime program ${runtime} is not deployed on ${cluster}`);
248
+ log(`cluster ${cluster} (${rpc.replace(/\?.*$/, '')}) · payer ${payer.publicKey.toBase58()} · runtime ${new PublicKey(runtime).toBase58()} · site ${site.toBase58()}${siteKp?.created ? ' (new)' : ''}`);
249
+
250
+ // Cost: site account + code + assets + manifest, all rent-exempt accounts.
251
+ const manifestOut = { ...manifest, target: 'shared', runtime: new PublicKey(runtime).toBase58(), site: site.toBase58(), cluster, rpc: rpc.replace(/\?.*$/, ''), deployedAt: new Date().toISOString() };
252
+ const manifestBytes = Buffer.byteLength(JSON.stringify(manifestOut));
253
+ const items = [{ label: 'site account', bytes: 66 }, { label: `code module (${code.length.toLocaleString()} B)`, bytes: code.length + 20 }];
254
+ for (const f of staticPlan) items.push({ label: f.path, bytes: (f.size ?? 0) + 7 + (f.contentType || '').length });
255
+ items.push({ label: '/.zoo/manifest.json', bytes: manifestBytes + 40 });
256
+ let total = 0;
257
+ for (const it of items) { it.lamports = await connection.getMinimumBalanceForRentExemption(it.bytes); total += it.lamports; }
258
+ const fees = 5000 * (3 + staticPlan.length * 3 + Math.ceil(code.length / 900) + 4);
259
+ log(' item bytes SOL');
260
+ for (const it of items) log(` ${it.label.padEnd(34)}${String(it.bytes).padStart(8)} ${(it.lamports / LAMPORTS_PER_SOL).toFixed(4).padStart(8)}`);
261
+ log(` ${'rent total'.padEnd(34)}${''.padStart(8)} ${(total / LAMPORTS_PER_SOL).toFixed(4).padStart(8)}`);
262
+ log(` ${'+ tx fees (approx.)'.padEnd(34)}${''.padStart(8)} ${(fees / LAMPORTS_PER_SOL).toFixed(4).padStart(8)}`);
263
+ log(` ${'TOTAL'.padEnd(34)}${''.padStart(8)} ${((total + fees) / LAMPORTS_PER_SOL).toFixed(4).padStart(8)}`);
264
+ if (mainnet && !o.yes) throw new Error('this is mainnet: pass --yes to spend the amount above');
265
+ const balance = await connection.getBalance(payer.publicKey);
266
+ if (balance < total + fees) throw new Error(`payer has ${(balance / LAMPORTS_PER_SOL).toFixed(4)} SOL, needs ~${((total + fees) / LAMPORTS_PER_SOL).toFixed(4)}`);
267
+
268
+ const t0 = Date.now();
269
+ const si = await initSite(connection, { authority: payer, runtime, site });
270
+ log(`${si.created ? 'created' : 'reusing'} site account ${si.pda.toBase58()}`);
271
+ const cr = await putAsset(connection, { authority: payer, programId: runtime, site, path: CODE_PATH, contentType: 'application/x-zoo-bytecode', data: code, force: o.force });
272
+ log(`code → ${CODE_PATH} (${cr.pda.toBase58()}, ${code.length.toLocaleString()} B${cr.skipped ? ', unchanged' : ''})`);
273
+ let uploaded = 0, skipped = 0, failed = 0;
274
+ const failures = [];
275
+ if (!o.skipAssets) {
276
+ const files = staticPlan.filter((f) => f.file || f.path);
277
+ let done = 0;
278
+ await pool(files, async (f) => {
279
+ try {
280
+ const data = fs.readFileSync(f.file);
281
+ const r = await putAsset(connection, { authority: payer, programId: runtime, site, path: f.path, contentType: f.contentType || 'application/octet-stream', data, force: o.force });
282
+ if (r.skipped) skipped++; else uploaded++;
283
+ log(`[${++done}/${files.length}] ${f.path} ${r.skipped ? 'unchanged' : `${data.length.toLocaleString()} B in ${r.txs} tx`}`);
284
+ } catch (e) { failed++; failures.push({ path: f.path, error: String(e?.message || e) }); log(`[${++done}/${files.length}] ${f.path} FAILED: ${e?.message || e}`); }
285
+ }, { concurrency: o.concurrency ? Number(o.concurrency) : 4 });
286
+ log(`assets: ${uploaded} uploaded, ${skipped} unchanged, ${failed} failed`);
287
+ }
288
+ const mr = await putAsset(connection, { authority: payer, programId: runtime, site, path: MANIFEST_PATH, contentType: MANIFEST_CONTENT_TYPE, data: Buffer.from(JSON.stringify(manifestOut)), force: true });
289
+ log(`manifest → ${MANIFEST_PATH} (${mr.pda.toBase58()}, ${manifestBytes.toLocaleString()} B) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
290
+ const out = { target: 'shared', site: site.toBase58(), programId: site.toBase58(), runtime: new PublicKey(runtime).toBase58(), sitePda: si.pda.toBase58(), manifestPda: mr.pda.toBase58(), assets: { uploaded, skipped, failed }, cluster, rpc, deployedAt: manifestOut.deployedAt, failures, url: `${process.env.OPENZOO_HUB_URL || 'https://sites.openzoo.fun'}/s/${site.toBase58()}` };
291
+ writeJson(path.join(zooDir, 'deploy.json'), out);
292
+ log(`\nserve it: openzoo-transmute serve ${site.toBase58()} --runtime ${new PublicKey(runtime).toBase58()} --cluster ${cluster}`);
293
+ log(`or open ${out.url}`);
294
+ return out;
295
+ }
296
+
297
+ // ---------------------------------------------------------------- runtime program
298
+
299
+ const HERE_DEPLOY = path.dirname(new URL(import.meta.url).pathname);
300
+ export const PREBUILT_DIR = path.resolve(HERE_DEPLOY, '..', 'prebuilt');
301
+ export const RUNTIME_CRATE = path.resolve(HERE_DEPLOY, '..', 'runtime', 'zoo-vm');
302
+
303
+ /** The zoo-vm .so for an SBPF arch: prebuilt (v0 = mainnet) or built from runtime/zoo-vm. */
304
+ export async function runtimeSoFor(arch, { log = console.log, so } = {}) {
305
+ if (so) return { soPath: path.resolve(so), source: 'given' };
306
+ const pre = path.join(PREBUILT_DIR, `zoo_vm.${arch}.so`);
307
+ if (fs.existsSync(pre)) return { soPath: pre, source: 'prebuilt' };
308
+ if (!fs.existsSync(path.join(RUNTIME_CRATE, 'Cargo.toml'))) throw new Error(`no prebuilt zoo-vm for ${arch} (${pre}) and no runtime/zoo-vm crate to build it from`);
309
+ const r = await runCargoBuildSbf(RUNTIME_CRATE, { arch, outDir: path.join(RUNTIME_CRATE, 'target', 'deploy-' + arch), log });
310
+ if (!r.ok) throw new Error(r.missing ? `no prebuilt zoo-vm for ${arch}; install the toolchain to build it: ${INSTALL_HINT}` : `zoo-vm build failed: ${r.error}`);
311
+ return { soPath: r.soPath, source: 'built' };
312
+ }
313
+
314
+ /**
315
+ * Deploy (or upgrade) the shared runtime program once per cluster. Sites then
316
+ * cost only their accounts. Keypair kept at `programKeypair` (default
317
+ * ./zoo-vm-keypair.json) so a redeploy upgrades in place.
318
+ */
319
+ export async function deployRuntime(o = {}) {
320
+ const log = o.log ?? console.log;
321
+ const rpc = rpcUrl(o.cluster);
322
+ const cluster = clusterLabel(o.cluster);
323
+ const connection = o.connection || connect(rpc);
324
+ let mainnet = isMainnet(o.cluster, rpc);
325
+ if ((await genesisCluster(connection)) === 'mainnet') mainnet = true;
326
+ const wallet = o.wallet || loadWallet({ keypair: o.keypair });
327
+ const payer = wallet.keypair;
328
+ const arch = await detectSbpfArch(connection);
329
+ const { soPath, source } = await runtimeSoFor(arch, { log, so: o.so });
330
+ const so = fs.readFileSync(soPath);
331
+ const kpPath = path.resolve(o.programKeypair || 'zoo-vm-keypair.json');
332
+ let programKeypair;
333
+ if (fs.existsSync(kpPath)) programKeypair = Keypair.fromSecretKey(Uint8Array.from(readJson(kpPath)));
334
+ else { programKeypair = Keypair.generate(); fs.writeFileSync(kpPath, JSON.stringify([...programKeypair.secretKey]), { mode: 0o600 }); log(`new runtime keypair → ${kpPath} (keep it: redeploys upgrade in place)`); }
335
+ const programId = programKeypair.publicKey;
336
+ const info = await getProgramInfo(connection, programId);
337
+ const upgrade = info.exists;
338
+ const est = await estimateCost({ staticFiles: [], soSize: so.length, manifestBytes: 0, connection, upgrade });
339
+ est.items = est.items.filter((i) => i.kind !== 'asset');
340
+ est.lamports = est.items.filter((i) => !i.transient).reduce((n, i) => n + i.lamports, 0); est.sol = est.lamports / LAMPORTS_PER_SOL;
341
+ est.totalLamports = est.lamports + est.transientLamports + est.feeLamports; est.totalSol = est.totalLamports / LAMPORTS_PER_SOL;
342
+ log(`cluster ${cluster} (${rpc.replace(/\?.*$/, '')}) · payer ${payer.publicKey.toBase58()} · runtime ${programId.toBase58()} (${upgrade ? 'upgrade' : 'new'}) · ${path.basename(soPath)} ${so.length.toLocaleString()} B ${arch} (${source})`);
343
+ log(formatCostTable(est));
344
+ const bal = await connection.getBalance(payer.publicKey);
345
+ if (bal < est.totalLamports) throw new Error(`payer has ${(bal / LAMPORTS_PER_SOL).toFixed(4)} SOL, needs ≈ ${est.totalSol.toFixed(4)} SOL`);
346
+ if (mainnet && !o.yes) throw new Error(`refusing to spend ≈ ${est.totalSol.toFixed(4)} SOL on mainnet without --yes`);
347
+ let done = 0; const total = Math.ceil(so.length / 900);
348
+ const onProgress = (n) => { done += 1; if (done % 25 === 0 || done === total) log(` wrote ${Math.min(done, total)}/${total} chunks`); };
349
+ const t0 = Date.now();
350
+ const r = upgrade
351
+ ? await upgradeProgram(connection, { payer, programId, so, onProgress })
352
+ : await deployProgram(connection, { payer, programKeypair, so, onProgress, headroom: o.headroom ? Number(o.headroom) : 1 });
353
+ log(`${upgrade ? 'upgraded' : 'deployed'} runtime ${programId.toBase58()} in ${((Date.now() - t0) / 1000).toFixed(1)}s (${r.signature})`);
354
+ log(`\nsites now deploy against it: openzoo-transmute build <dir> --target shared && openzoo-transmute deploy <dir> --runtime ${programId.toBase58()} --cluster ${cluster}`);
355
+ log(`hub: OPENZOO_VM_PROGRAM=${programId.toBase58()}`);
356
+ return { programId: programId.toBase58(), signature: r.signature, upgrade, arch, soPath, cluster, rpc, keypairPath: kpPath };
357
+ }
@@ -15,8 +15,8 @@
15
15
  import http from 'node:http';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { PublicKey, Transaction, TransactionInstruction, ComputeBudgetProgram, SystemProgram } from '@solana/web3.js';
18
- import { FORWARDED_HEADERS, MANIFEST_PATH, assetPda, decodeAsset, encodeInvoke } from './wire.js';
19
- import { connect, invoke as chainInvoke, readManifest, getProgramInfo, DEFAULT_CU, DEFAULT_HEAP } from './solana.js';
18
+ import { FORWARDED_HEADERS, MANIFEST_PATH, assetPda, assetPdaFor, decodeAsset, encodeInvoke } from './wire.js';
19
+ import { connect, invoke as chainInvoke, readManifest, getProgramInfo, getSiteInfo, DEFAULT_CU, DEFAULT_HEAP } from './solana.js';
20
20
  import { rpcUrl } from './wallet.js';
21
21
 
22
22
  export const DEFAULT_PORT = 4402;
@@ -46,9 +46,14 @@ const HTML_CT = 'text/html; charset=utf-8';
46
46
  export function makeState(o) {
47
47
  const programId = new PublicKey(o.programId);
48
48
  const cluster = o.cluster || process.env.OPENZOO_CLUSTER || 'localnet';
49
+ // Shared runtime: `programId` is the runtime program and `site` the site id;
50
+ // every PDA is namespaced by it and the invoke carries it.
51
+ const site = o.site ? new PublicKey(o.site) : null;
49
52
  const state = {
50
53
  programId,
51
54
  programIdStr: programId.toBase58(),
55
+ site,
56
+ siteStr: site ? site.toBase58() : null,
52
57
  cluster,
53
58
  rpc: o.connection?.rpcEndpoint || safeRpcUrl(cluster),
54
59
  connection: o.connection ?? null,
@@ -103,7 +108,7 @@ export async function getAsset(state, path) {
103
108
  const cached = state.assets.get(path);
104
109
  if (cached && now - cached.at < state.cacheTtlMs) return cached;
105
110
  if (!state.connection) return cached ?? { missing: true, at: now };
106
- const [pda] = assetPda(state.programId, path);
111
+ const [pda] = assetPdaFor(state.programId, state.site, path);
107
112
  let entry;
108
113
  try {
109
114
  const info = await state.connection.getAccountInfo(pda);
@@ -384,7 +389,7 @@ async function serveFunction(state, hit, req, pathname, query, extraHeaders = {}
384
389
  state.stats.invokes++;
385
390
  let r;
386
391
  try {
387
- r = await state.invoke({ programId: state.programId, payer, event, mutate });
392
+ r = await state.invoke({ programId: state.programId, site: state.site, payer, event, mutate });
388
393
  } catch (e) {
389
394
  const msg = String(e?.message || e);
390
395
  if (/signing keypair is required/i.test(msg)) return json(402, { error: 'payment required', message: msg, method, path: pathname }, { 'x-zoo-program': state.programIdStr });
@@ -424,10 +429,11 @@ export async function statusOf(state) {
424
429
  let chain = null;
425
430
  if (state.connection) {
426
431
  try {
427
- const [info, slot] = await Promise.all([getProgramInfo(state.connection, state.programId), state.connection.getSlot('confirmed')]);
432
+ const [info, slot, siteInfo] = await Promise.all([getProgramInfo(state.connection, state.programId), state.connection.getSlot('confirmed'), state.site ? getSiteInfo(state.connection, state.programId, state.site) : null]);
428
433
  chain = {
429
434
  slot,
430
435
  program: { exists: info.exists, executable: info.executable ?? null, authority: info.authority ? info.authority.toBase58() : null, deploySlot: info.slot, maxDataLen: info.maxDataLen, programData: info.programData.toBase58() },
436
+ site: siteInfo ? { id: state.siteStr, exists: siteInfo.exists, authority: siteInfo.authority ? siteInfo.authority.toBase58() : null, account: siteInfo.pda.toBase58() } : null,
431
437
  };
432
438
  } catch (e) { chain = { error: String(e?.message || e) }; }
433
439
  }
@@ -660,13 +666,13 @@ function readBody(req, limit) {
660
666
  * Serve a program over HTTP. Resolves once listening.
661
667
  * @returns {Promise<{server:import('node:http').Server, port:number, url:string, state:object, close:()=>Promise<void>}>}
662
668
  */
663
- export async function startGateway({ programId, cluster, port = DEFAULT_PORT, host = '127.0.0.1', keypair, manifest, invoke, connection, cacheTtlMs, log = console.log, quiet = false } = {}) {
669
+ export async function startGateway({ programId, site = null, cluster, port = DEFAULT_PORT, host = '127.0.0.1', keypair, manifest, invoke, connection, cacheTtlMs, log = console.log, quiet = false } = {}) {
664
670
  const rpc = safeRpcUrl(cluster || process.env.OPENZOO_CLUSTER || 'localnet');
665
671
  connection = connection ?? (rpc ? connect(rpc) : null);
666
- const state = makeState({ programId, cluster, connection, keypair, manifest, invoke, cacheTtlMs, port, log: quiet ? () => {} : log });
672
+ const state = makeState({ programId, site, cluster, connection, keypair, manifest, invoke, cacheTtlMs, port, log: quiet ? () => {} : log });
667
673
  if (!manifest && connection) {
668
674
  try {
669
- const m = await readManifest(connection, state.programId);
675
+ const m = await readManifest(connection, state.programId, state.site);
670
676
  if (m) { state.manifest = normalizeManifest(m); state.manifestAt = Date.now(); }
671
677
  else if (!quiet) log(`warning: no manifest at ${MANIFEST_PATH} for ${state.programIdStr}; routing by on-chain lookup only`);
672
678
  } catch (e) { if (!quiet) log(`warning: could not read the manifest: ${e?.message || e}`); }
@@ -20,7 +20,7 @@
20
20
  import http from 'node:http';
21
21
  import { PublicKey } from '@solana/web3.js';
22
22
  import { makeState, normalizeManifest, handleRequest, safeResponseHeaders, PACKET_DATA_SIZE, BODY_LIMIT } from './gateway.js';
23
- import { connect, readManifest } from './solana.js';
23
+ import { connect, readManifest, getSiteInfo } from './solana.js';
24
24
  import { rpcUrl } from './wallet.js';
25
25
  import { MANIFEST_PATH } from './wire.js';
26
26
 
@@ -80,6 +80,9 @@ export function makeHub(o = {}) {
80
80
  startedAt: Date.now(),
81
81
  stats: { requests: 0, sitesLoaded: 0, writes: 0, writesRefused: 0, spentLamports: 0 },
82
82
  publicUrl: o.publicUrl || null,
83
+ // The shared runtime program on this cluster: ids that are site accounts
84
+ // under it are served as shared sites; anything else as a compiled program.
85
+ runtime: o.runtime || process.env.OPENZOO_VM_PROGRAM || null,
83
86
  // write governor: per-IP token bucket + a daily lamport budget for the signer
84
87
  writesPerMin: o.writesPerMin ?? Number(process.env.OPENZOO_HUB_WRITES_PER_MIN || 3),
85
88
  budgetLamports: Math.round((o.writeBudgetSol ?? Number(process.env.OPENZOO_HUB_WRITE_BUDGET_SOL || 0.05)) * 1e9),
@@ -115,9 +118,15 @@ async function siteFor(hub, programId) {
115
118
  let state;
116
119
  if (hub.makeSite) state = await hub.makeSite(programId);
117
120
  else {
118
- state = makeState({ programId, cluster: hub.cluster, connection: hub.connection, keypair: hub.keypair, log: hub.log, cacheTtlMs: hub.manifestTtlMs });
121
+ let shared = false;
122
+ if (hub.runtime) {
123
+ try { shared = (await getSiteInfo(hub.connection, hub.runtime, programId)).exists; } catch { /* fall through to program mode */ }
124
+ }
125
+ state = shared
126
+ ? makeState({ programId: hub.runtime, site: programId, cluster: hub.cluster, connection: hub.connection, keypair: hub.keypair, log: hub.log, cacheTtlMs: hub.manifestTtlMs })
127
+ : makeState({ programId, cluster: hub.cluster, connection: hub.connection, keypair: hub.keypair, log: hub.log, cacheTtlMs: hub.manifestTtlMs });
119
128
  try {
120
- const m = await readManifest(hub.connection, state.programId);
129
+ const m = await readManifest(hub.connection, state.programId, state.site);
121
130
  if (m) { state.manifest = normalizeManifest(m); state.manifestAt = Date.now(); }
122
131
  else state.noManifest = true;
123
132
  } catch (e) { state.manifestError = String(e?.message || e); }
@@ -135,6 +144,7 @@ function siteSummary(id, entry) {
135
144
  const m = entry.state.manifest || {};
136
145
  return {
137
146
  programId: id,
147
+ mode: entry.state.site ? 'shared' : 'program',
138
148
  name: m.name || null,
139
149
  framework: m.framework || null,
140
150
  routes: (m.routes || []).length,
@@ -9,6 +9,8 @@ import {
9
9
  BPF_LOADER_UPGRADEABLE, ERR_KV_MISSING, encodeInvoke, parseLogs, decodeResponse, programDataPda,
10
10
  assetPda, encodeAssetInit, encodeAssetWrite, encodeAssetClose, decodeAsset, ASSET_FIXED_HEADER,
11
11
  ASSET_MAX_INITIAL, kvPda, decodeKv, MANIFEST_PATH,
12
+ sitePda, assetPdaFor, kvPdaFor, encodeSiteInit, encodeVmInvoke, encodeVmAssetInit, encodeVmAssetWrite, encodeVmAssetClose,
13
+ decodeSite, CODE_PATH,
12
14
  } from './wire.js';
13
15
 
14
16
  export const WRITE_CHUNK = 900;
@@ -158,10 +160,11 @@ export async function upgradeProgram(connection, { payer, authority = payer, pro
158
160
 
159
161
  // ---------------------------------------------------------------- assets
160
162
 
161
- function assetKeys(authority, programId, pda) {
163
+ function assetKeys(authority, programId, pda, site = null) {
162
164
  return [
163
165
  { pubkey: authority.publicKey, isSigner: true, isWritable: true },
164
- { pubkey: programDataPda(programId), isSigner: false, isWritable: false },
166
+ // proof of authority: the program's ProgramData (compiled site) or the site account (shared runtime)
167
+ { pubkey: site ? sitePda(programId, site)[0] : programDataPda(programId), isSigner: false, isWritable: false },
165
168
  { pubkey: pda, isSigner: false, isWritable: true },
166
169
  { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
167
170
  ];
@@ -176,55 +179,59 @@ export async function assetRent(connection, len, contentType = '') {
176
179
  * Upload one asset (create/re-init + chunked writes). Skips the upload when
177
180
  * the on-chain bytes already match.
178
181
  */
179
- export async function putAsset(connection, { authority, programId, path, contentType, data, onProgress, force = false }) {
182
+ export async function putAsset(connection, { authority, programId, site = null, path, contentType, data, onProgress, force = false }) {
180
183
  const pid = new PublicKey(programId);
181
- const [pda] = assetPda(pid, path);
184
+ const [pda] = assetPdaFor(pid, site, path);
185
+ const initData = site ? encodeVmAssetInit(site, path, data.length, contentType) : encodeAssetInit(path, data.length, contentType);
186
+ // The shared-runtime write carries the 32-byte site id; keep the tx ≤ 1232 B.
187
+ const chunk = site ? WRITE_CHUNK - 32 : WRITE_CHUNK;
188
+ const writeData = (off) => site ? encodeVmAssetWrite(site, path, off, data.subarray(off, off + chunk)) : encodeAssetWrite(path, off, data.subarray(off, off + chunk));
182
189
  const existing = await connection.getAccountInfo(pda);
183
190
  if (existing && !force) {
184
191
  const cur = decodeAsset(existing.data);
185
192
  if (cur && cur.complete && cur.contentType === contentType && cur.data.equals(data)) return { pda, skipped: true };
186
193
  }
187
- const keys = assetKeys(authority, pid, pda);
188
- await sendTx(connection, [new TransactionInstruction({ programId: pid, keys, data: encodeAssetInit(path, data.length, contentType) })], [authority], { label: `init ${path}` });
194
+ const keys = assetKeys(authority, pid, pda, site);
195
+ await sendTx(connection, [new TransactionInstruction({ programId: pid, keys, data: initData })], [authority], { label: `init ${path}` });
189
196
  const header = ASSET_FIXED_HEADER + Buffer.byteLength(contentType.slice(0, 120));
190
197
  // The first `ASSET_MAX_INITIAL - header` bytes fit in the created account;
191
198
  // later chunks grow it ≤10 KB per tx. Both are just sequential offsets.
192
199
  const chunks = [];
193
- for (let off = 0; off < data.length; off += WRITE_CHUNK) chunks.push(off);
200
+ for (let off = 0; off < data.length; off += chunk) chunks.push(off);
194
201
  // Writes past the initial allocation resize the account, and a resize's
195
202
  // budget is measured from the length at instruction entry; keep those in
196
203
  // order so no two growing writes race each other.
197
204
  const initialCap = Math.max(0, ASSET_MAX_INITIAL - header);
198
- const inPlace = chunks.filter((o) => o + WRITE_CHUNK <= initialCap);
199
- const growing = chunks.filter((o) => o + WRITE_CHUNK > initialCap);
200
- const write = (off) => sendTx(connection, [new TransactionInstruction({ programId: pid, keys, data: encodeAssetWrite(path, off, data.subarray(off, off + WRITE_CHUNK)) })], [authority], { label: `write ${path}@${off}` });
205
+ const inPlace = chunks.filter((o) => o + chunk <= initialCap);
206
+ const growing = chunks.filter((o) => o + chunk > initialCap);
207
+ const write = (off) => sendTx(connection, [new TransactionInstruction({ programId: pid, keys, data: writeData(off) })], [authority], { label: `write ${path}@${off}` });
201
208
  let done = 0;
202
209
  await pool(inPlace, async (off) => { await write(off); onProgress?.(++done, chunks.length); }, { concurrency: 8 });
203
210
  for (const off of growing) { await write(off); onProgress?.(++done, chunks.length); }
204
211
  return { pda, skipped: false, txs: 1 + chunks.length };
205
212
  }
206
213
 
207
- export async function closeAsset(connection, { authority, programId, path }) {
214
+ export async function closeAsset(connection, { authority, programId, site = null, path }) {
208
215
  const pid = new PublicKey(programId);
209
- const [pda] = assetPda(pid, path);
210
- return sendTx(connection, [new TransactionInstruction({ programId: pid, keys: assetKeys(authority, pid, pda), data: encodeAssetClose(path) })], [authority], { label: `close ${path}` });
216
+ const [pda] = assetPdaFor(pid, site, path);
217
+ return sendTx(connection, [new TransactionInstruction({ programId: pid, keys: assetKeys(authority, pid, pda, site), data: site ? encodeVmAssetClose(site, path) : encodeAssetClose(path) })], [authority], { label: `close ${path}` });
211
218
  }
212
219
 
213
- export async function readAsset(connection, programId, path) {
214
- const [pda] = assetPda(new PublicKey(programId), path);
220
+ export async function readAsset(connection, programId, path, site = null) {
221
+ const [pda] = assetPdaFor(new PublicKey(programId), site, path);
215
222
  const info = await connection.getAccountInfo(pda);
216
223
  if (!info) return null;
217
224
  return decodeAsset(info.data);
218
225
  }
219
226
 
220
- export async function readManifest(connection, programId) {
221
- const a = await readAsset(connection, programId, MANIFEST_PATH);
227
+ export async function readManifest(connection, programId, site = null) {
228
+ const a = await readAsset(connection, programId, MANIFEST_PATH, site);
222
229
  if (!a || !a.complete) return null;
223
230
  return JSON.parse(a.data.toString('utf8'));
224
231
  }
225
232
 
226
- export async function readKv(connection, programId, key) {
227
- const [pda] = kvPda(new PublicKey(programId), key);
233
+ export async function readKv(connection, programId, key, site = null) {
234
+ const [pda] = kvPdaFor(new PublicKey(programId), site, key);
228
235
  const info = await connection.getAccountInfo(pda);
229
236
  return info ? decodeKv(info.data) : null;
230
237
  }
@@ -242,9 +249,15 @@ export async function readKv(connection, programId, key) {
242
249
  *
243
250
  * @returns {{status:number, headers:object, body:Buffer, signature?:string, logs:string[], unitsConsumed?:number, accounts:string[]}}
244
251
  */
245
- export async function invoke(connection, { programId, payer, event, mutate = false, computeUnits = DEFAULT_CU, heap = DEFAULT_HEAP, maxDiscovery = 6 }) {
252
+ export async function invoke(connection, { programId, site = null, payer, event, mutate = false, computeUnits = DEFAULT_CU, heap = DEFAULT_HEAP, maxDiscovery = 6 }) {
246
253
  const pid = new PublicKey(programId);
247
254
  const payerKey = payer.publicKey ?? new PublicKey(payer);
255
+ // Shared runtime: the instruction names the site and hands the program its
256
+ // site account and code account after payer + system program.
257
+ const siteKeys = site ? [
258
+ { pubkey: sitePda(pid, site)[0], isSigner: false, isWritable: false },
259
+ { pubkey: assetPdaFor(pid, site, CODE_PATH)[0], isSigner: false, isWritable: false },
260
+ ] : [];
248
261
  if (mutate) {
249
262
  // Identical bytes inside one blockhash window dedupe as AlreadyProcessed;
250
263
  // a per-request nonce header (ignored on chain) makes every write unique
@@ -252,12 +265,13 @@ export async function invoke(connection, { programId, payer, event, mutate = fal
252
265
  const nonce = Math.random().toString(16).slice(2, 10) + Date.now().toString(16);
253
266
  event = { ...event, headers: typeof event.headers === 'string' ? event.headers + `x-zoo-nonce:${nonce}\n` : { ...(event.headers || {}), 'x-zoo-nonce': nonce } };
254
267
  }
255
- const data = encodeInvoke(event);
268
+ const data = site ? encodeVmInvoke(site, event) : encodeInvoke(event);
256
269
  const extra = [];
257
270
  for (let round = 0; ; round++) {
258
271
  const keys = [
259
272
  { pubkey: payerKey, isSigner: true, isWritable: true },
260
273
  { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
274
+ ...siteKeys,
261
275
  ...extra.map((k) => ({ pubkey: k, isSigner: false, isWritable: true })),
262
276
  ];
263
277
  const ix = new TransactionInstruction({ programId: pid, keys, data });
@@ -339,3 +353,35 @@ export async function waitForProgram(connection, programId, { timeoutMs = 30_000
339
353
  await new Promise((r) => setTimeout(r, 400));
340
354
  }
341
355
  }
356
+
357
+ // ---------------------------------------------------------------- shared runtime sites
358
+
359
+ /** Create the site account (`["site", siteId]`) under the runtime, owned by `authority`. Idempotent. */
360
+ export async function initSite(connection, { authority, runtime, site }) {
361
+ const pid = new PublicKey(runtime);
362
+ const [pda] = sitePda(pid, site);
363
+ const info = await connection.getAccountInfo(pda);
364
+ if (info) {
365
+ const s = decodeSite(info.data);
366
+ if (s && !s.authority.equals(authority.publicKey)) throw new Error(`site ${new PublicKey(site).toBase58()} belongs to ${s.authority.toBase58()}`);
367
+ return { pda, created: false };
368
+ }
369
+ await sendTx(connection, [new TransactionInstruction({
370
+ programId: pid,
371
+ keys: [
372
+ { pubkey: authority.publicKey, isSigner: true, isWritable: true },
373
+ { pubkey: pda, isSigner: false, isWritable: true },
374
+ { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
375
+ ],
376
+ data: encodeSiteInit(site),
377
+ })], [authority], { label: 'site init' });
378
+ return { pda, created: true };
379
+ }
380
+
381
+ export async function getSiteInfo(connection, runtime, site) {
382
+ const [pda] = sitePda(new PublicKey(runtime), site);
383
+ const info = await connection.getAccountInfo(pda);
384
+ if (!info) return { exists: false, pda };
385
+ const s = decodeSite(info.data);
386
+ return { exists: !!s, pda, authority: s?.authority || null };
387
+ }
@@ -7,7 +7,9 @@
7
7
  import { createHash } from 'node:crypto';
8
8
  import { PublicKey } from '@solana/web3.js';
9
9
 
10
- export const TAG = { INVOKE: 0, ASSET_INIT: 1, ASSET_WRITE: 2, ASSET_CLOSE: 3 };
10
+ export const TAG = { INVOKE: 0, ASSET_INIT: 1, ASSET_WRITE: 2, ASSET_CLOSE: 3, SITE_INIT: 16, SITE_SET_AUTHORITY: 17, VM_INVOKE: 18, VM_ASSET_INIT: 19, VM_ASSET_WRITE: 20, VM_ASSET_CLOSE: 21 };
11
+ /** Path of a site's bytecode module under the shared runtime. */
12
+ export const CODE_PATH = '/.zoo/code.bin';
11
13
  export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'];
12
14
  export const ERR_KV_MISSING = 0x4b560001;
13
15
  export const ERR_NOT_AUTHORITY = 0x4b560002;
@@ -165,3 +167,42 @@ export function decodeKv(data) {
165
167
  if (len === 0) return null;
166
168
  try { return JSON.parse(data.subarray(KV_HEADER, KV_HEADER + len).toString('utf8')); } catch { return null; }
167
169
  }
170
+
171
+ // ---- shared runtime (zoo-vm): sites are accounts namespaced by a 32-byte site id
172
+
173
+ export function siteIdBytes(site) { return site instanceof PublicKey ? site.toBuffer() : new PublicKey(site).toBuffer(); }
174
+
175
+ export function sitePda(runtime, site) {
176
+ return PublicKey.findProgramAddressSync([Buffer.from('site'), siteIdBytes(site)], new PublicKey(runtime));
177
+ }
178
+ export function kvPdaNs(runtime, site, key) {
179
+ return PublicKey.findProgramAddressSync([Buffer.from('kv'), siteIdBytes(site), kvHash(key)], new PublicKey(runtime));
180
+ }
181
+ export function assetPdaNs(runtime, site, path) {
182
+ return PublicKey.findProgramAddressSync([Buffer.from('asset'), siteIdBytes(site), assetHash(path)], new PublicKey(runtime));
183
+ }
184
+ /** Any-mode asset PDA: program-flavour when `site` is null. */
185
+ export function assetPdaFor(programId, site, path) {
186
+ return site ? assetPdaNs(programId, site, path) : assetPda(programId, path);
187
+ }
188
+ export function kvPdaFor(programId, site, key) {
189
+ return site ? kvPdaNs(programId, site, key) : kvPda(programId, key);
190
+ }
191
+
192
+ export function encodeSiteInit(site) { return Buffer.concat([Buffer.from([TAG.SITE_INIT]), siteIdBytes(site)]); }
193
+ export function encodeSiteSetAuthority(site, newAuthority) { return Buffer.concat([Buffer.from([TAG.SITE_SET_AUTHORITY]), siteIdBytes(site), new PublicKey(newAuthority).toBuffer()]); }
194
+ /** The VM invoke: same bridge event, prefixed by the site id. */
195
+ export function encodeVmInvoke(site, ev) {
196
+ const inner = encodeInvoke(ev); // [TAG.INVOKE][...]
197
+ return Buffer.concat([Buffer.from([TAG.VM_INVOKE]), siteIdBytes(site), inner.subarray(1)]);
198
+ }
199
+ export function encodeVmAssetInit(site, path, totalLen, contentType) { return Buffer.concat([Buffer.from([TAG.VM_ASSET_INIT]), siteIdBytes(site), encodeAssetInit(path, totalLen, contentType).subarray(1)]); }
200
+ export function encodeVmAssetWrite(site, path, offset, bytes) { return Buffer.concat([Buffer.from([TAG.VM_ASSET_WRITE]), siteIdBytes(site), encodeAssetWrite(path, offset, bytes).subarray(1)]); }
201
+ export function encodeVmAssetClose(site, path) { return Buffer.concat([Buffer.from([TAG.VM_ASSET_CLOSE]), siteIdBytes(site), encodeAssetClose(path).subarray(1)]); }
202
+
203
+ export const SITE_LEN = 66;
204
+ /** Decode a site account: { authority, bump } or null. */
205
+ export function decodeSite(data) {
206
+ if (!data || data.length < SITE_LEN || data[0] !== 1) return null;
207
+ return { bump: data[1], authority: new PublicKey(data.subarray(2, 34)) };
208
+ }
Binary file
@@ -0,0 +1,43 @@
1
+ //! Host-side VM harness: `vmrun <module.bin> <route> <method> <path> [query] [body] [headers]`
2
+ //! prints the bridge response as JSON. No chain: KV calls report as missing.
3
+ use std::io::Read;
4
+ use zoo_host::{vm, wire::Req, Ctx};
5
+
6
+ fn main() {
7
+ let args: Vec<String> = std::env::args().collect();
8
+ let mut code = Vec::new();
9
+ std::fs::File::open(&args[1]).unwrap().read_to_end(&mut code).unwrap();
10
+ let route: usize = args[2].parse().unwrap();
11
+ let method = zoo_host::wire::METHODS.iter().position(|m| *m == args[3]).unwrap_or(0) as u8;
12
+ let req = Req {
13
+ route: route as u8,
14
+ method,
15
+ path: args.get(4).cloned().unwrap_or_else(|| "/".into()),
16
+ query: args.get(5).cloned().unwrap_or_default(),
17
+ headers: args.get(7).cloned().unwrap_or_default().replace("\\n", "\n"),
18
+ body: args.get(6).cloned().unwrap_or_default().into_bytes(),
19
+ };
20
+ let program_id = pinocchio::Address::new_from_array([7u8; 32]);
21
+ let mut accounts: [pinocchio::AccountView; 0] = [];
22
+ let mut cx = Ctx::new(&program_id, &mut accounts, req, &[]);
23
+ let out = vm::invoke_module(&mut cx, &code, route);
24
+ let status_body = match out {
25
+ Ok(()) if cx.sent => (cx.res.status, String::from_utf8_lossy(&cx.res.body).into_owned(), cx.res.headers.clone()),
26
+ Ok(()) => (504, "no response".into(), vec![]),
27
+ Err(e) => {
28
+ let r = vm::error_resp(&e);
29
+ (r.status, String::from_utf8_lossy(&r.body).into_owned(), r.headers.clone())
30
+ }
31
+ };
32
+ let hdrs: Vec<String> = status_body.2.iter().map(|(k, v)| format!("\"{}\":\"{}\"", k, v.replace('"', "\\\""))).collect();
33
+ println!("{{\"status\":{},\"headers\":{{{}}},\"body\":{}}}", status_body.0, hdrs.join(","), serde_free(&status_body.1));
34
+ }
35
+
36
+ fn serde_free(s: &str) -> String {
37
+ let mut o = String::from("\"");
38
+ for c in s.chars() {
39
+ match c { '"' => o.push_str("\\\""), '\\' => o.push_str("\\\\"), '\n' => o.push_str("\\n"), _ => o.push(c) }
40
+ }
41
+ o.push('"');
42
+ o
43
+ }