@terrariumlabs/core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrariumlabs/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A complete EVM chain inside the page, presented to your dapp as a wallet. revm in WebAssembly, real receipts, byte-identical to Anvil.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,7 +61,7 @@
61
61
  "@ethereumjs/util": "^10.1.3",
62
62
  "@ethereumjs/mpt": "^10.1.3",
63
63
  "@ethereumjs/rlp": "^10.0.0",
64
- "@terrariumlabs/evm": "^0.6.0"
64
+ "@terrariumlabs/evm": "^0.7.0"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "viem": "^2.0.0",
package/src/devbar.ts CHANGED
@@ -158,7 +158,7 @@ export function mountDevBar(provider: Provider, opts: DevBarOptions = {}) {
158
158
  // ---- the transaction explorer -----------------------------------------------------------------------------------------
159
159
  const panel = el(`<section id="terrarium-explorer" data-testid="explorer" role="region" aria-label="Transactions" hidden></section>`);
160
160
  const open = new Set<string>(); // expanded rows, by hash, kept across refreshes
161
- let lastRender = '', filter: 'all' | 'mine' | 'failed' = 'all', search = '', me: string | null = null; // `me`: accounts[0] from terrarium_status
161
+ let lastRender = '', lastTxKey = '', filter: 'all' | 'mine' | 'failed' = 'all', search = '', me: string | null = null; // `me`: accounts[0] from terrarium_status; `lastTxKey`: the counts the open explorer last saw
162
162
  const bTxs = btn('Transactions', 'txs', 'Every transaction on this chain, like a block explorer: receipt, decoded call, events, revert reason (Alt+Shift+X)', () => togglePanel());
163
163
  const togglePanel = async (show: boolean = !!panel.hidden) => { panel.hidden = !show; bTxs.classList.toggle('on', show); lastRender = ''; if (show) { await refreshTxs(); panel.querySelector<HTMLInputElement>('[data-testid=tx-search]')?.focus(); } place(); };
164
164
  const place = () => { panel.style.bottom = `${bar.offsetHeight}px`; toasts.style.bottom = `${bar.offsetHeight + (panel.hidden ? 0 : panel.offsetHeight) + 12}px`; }; // toasts sit above whatever is open
@@ -283,6 +283,7 @@ export function mountDevBar(provider: Provider, opts: DevBarOptions = {}) {
283
283
  if (s.restoredFromPersistence) notes.push(`${s.localBlocks} block${s.localBlocks === 1 ? '' : 's'} restored`);
284
284
  status.querySelector('[data-f=engine]')!.innerHTML = notes.map((n) => `<span>${n}</span>`).join('');
285
285
  status.title = `revm/wasm · the chain persists in IndexedDB${s.restoredFromPersistence ? ' (Reset to start clean)' : ''}`;
286
+ const txKey = JSON.stringify(s.txs ?? s.block);
286
287
  if (s.txs) { const bad = s.txs.failed ? `<span class="badge bad" title="${s.txs.failed} failed">${s.txs.failed} ✗</span>` : ''; const pend = s.txs.pending ? `<span class="badge" title="${s.txs.pending} pending">${s.txs.pending} ⏳</span>` : ''; bTxs.innerHTML = `Transactions<span class="badge">${s.txs.total}</span>${pend}${bad}`; }
287
288
  const ck = JSON.stringify(s.controls ?? []);
288
289
  if (ck !== controlsKey) { controlsKey = ck; controls.replaceChildren(...(s.controls ?? []).map((c: any, i: number) => btn(c.label, `control-${i}`, c.title ?? c.method, async () => { const r = await rpc(c.method, c.params ?? []); toast(`${c.label}${r !== undefined && r !== null && typeof r !== 'object' ? ` → ${r}` : ''}`); }))); }
@@ -292,7 +293,7 @@ export function mountDevBar(provider: Provider, opts: DevBarOptions = {}) {
292
293
  bLatency.textContent = s.wallet.latencyMs ? `Wallet: ${s.wallet.latencyMs / 1000}s delay` : 'Wallet: instant'; bLatency.classList.toggle('on', !!s.wallet.latencyMs);
293
294
  bLag.textContent = s.wallet.receiptLagMs ? `Receipts: ${s.wallet.receiptLagMs / 1000}s late` : 'Receipts: instant'; bLag.classList.toggle('on', !!s.wallet.receiptLagMs);
294
295
  if (!bar.hidden) place();
295
- if (!panel.hidden) await refreshTxs();
296
+ if (!panel.hidden && txKey !== lastTxKey) { lastTxKey = txKey; await refreshTxs(); } // refetch the list only when the chain's counts moved; clicks refetch on their own
296
297
  };
297
298
  refresh(); refreshScenarios();
298
299
  let ticks = 0;
package/src/engine.js CHANGED
@@ -191,7 +191,7 @@ export async function createTerrarium(opts = {}) {
191
191
  // (account) or is zero (slot). No round trip, no re-run. With a fork the truth may be remote: load it (recorded).
192
192
  const local = stateMode !== 'rpc';
193
193
  const revmHost = {
194
- account(address) {
194
+ account(address, wantCode = true) {
195
195
  const key = address.toLowerCase(); let a = mirrorGet('accounts', key);
196
196
  if (a === undefined) {
197
197
  if (local) { mirror[0].accounts.set(key, null); a = null; }
@@ -205,8 +205,9 @@ export async function createTerrarium(opts = {}) {
205
205
  }
206
206
  }
207
207
  if (a === null) return null;
208
- let code = '0x';
209
- if (a.codeHash !== KECCAK_EMPTY) { code = mirrorGet('code', key); if (code === undefined) { misses.push(() => mirrorLoad('code', key)); throw { missing: true }; } }
208
+ if (a.codeHash === KECCAK_EMPTY) return { balance: hex(a.balance), nonce: hex(a.nonce), codeHash: a.codeHash, code: '0x' };
209
+ if (!wantCode) return { balance: hex(a.balance), nonce: hex(a.nonce), codeHash: a.codeHash }; // no `code` key: wasm has it cached by hash, or asks again with wantCode
210
+ const code = mirrorGet('code', key); if (code === undefined) { misses.push(() => mirrorLoad('code', key)); throw { missing: true }; }
210
211
  return { balance: hex(a.balance), nonce: hex(a.nonce), codeHash: a.codeHash, code };
211
212
  },
212
213
  storage(address, slot) { const key = `${address.toLowerCase()}:${slot.toLowerCase()}`; const v = mirrorGet('storage', key); if (v !== undefined) return v; if (local) { mirror[0].storage.set(key, ZERO32); return ZERO32; } misses.push(() => mirrorLoad('storage', key)); throw { missing: true }; },
@@ -221,24 +222,32 @@ export async function createTerrarium(opts = {}) {
221
222
  for (const [slot, value] of c.storage) await sm.putStorage(a, hexToBytes(slot), hexToBytes(value));
222
223
  }
223
224
  }
224
- async function execRevm({ tx, msg, block, flags = {} }) {
225
+ function revmRequest({ tx, msg, block, flags = {} }) {
225
226
  let m = tx ? msgOf(tx) : { ...msg, gasPrice: 0n, priorityFee: 0n, nonce: 0n };
226
227
  if (!tx) flags = { ...flags, skipBalance: true, skipNonce: true, noBaseFee: true, skipEip3607: true };
227
- const req = JSON.stringify({ tx: { from: m.from, to: m.to, value: hex(m.value), data: m.data, gasLimit: hex(m.gasLimit), gasPrice: hex(m.gasPrice), priorityFee: hex(m.priorityFee), nonce: hex(m.nonce), txType: 2 },
228
+ return JSON.stringify({ tx: { from: m.from, to: m.to, value: hex(m.value), data: m.data, gasLimit: hex(m.gasLimit), gasPrice: hex(m.gasPrice), priorityFee: hex(m.priorityFee), nonce: hex(m.nonce), txType: 2 },
228
229
  block: { number: hex(block.header.number), timestamp: hex(block.header.timestamp), gasLimit: hex(block.header.gasLimit), baseFee: hex(block.header.baseFeePerGas ?? 0n) },
229
230
  cfg: { chainId, spec: String(hardfork), skipBalance: !!flags.skipBalance, skipNonce: !!flags.skipNonce, skipBlockGasLimit: true, noBaseFee: !!flags.noBaseFee, skipEip3607: !!flags.skipEip3607, traceSloads: !!flags.traceSloads } });
230
- stats.runs++;
231
+ }
232
+ /** one wasm call (`run` or `estimate`), re-issued after each round of state fetching in fork mode */
233
+ async function revmCall(fn, req) {
231
234
  for (let round = 0; ; round++) {
232
235
  misses.length = 0; stats.rounds++;
233
- let out;
234
236
  const t0 = Date.now();
235
- try { out = JSON.parse(revm.run(revmHost, req)); stats.wasmMs += Date.now() - t0; }
237
+ try { const out = JSON.parse(fn(revmHost, req)); stats.wasmMs += Date.now() - t0; return out; }
236
238
  catch (e) {
237
239
  stats.wasmMs += Date.now() - t0;
238
240
  if (misses.length) { if (round > 100000) throw new Error('revm: state loading did not converge'); for (const load of misses.splice(0)) await load(); continue; }
239
241
  const message = String(e?.message ?? e);
240
242
  throw new Error(message.startsWith('invalid:') ? message : `revm: ${message}`);
241
243
  }
244
+ }
245
+ }
246
+ async function execRevm(args) {
247
+ const req = revmRequest(args);
248
+ stats.runs++;
249
+ {
250
+ const out = await revmCall(revm.run, req);
242
251
  await applyRevmState(out.state);
243
252
  return { success: out.success, error: out.success ? null : out.reason, gasUsed: BigInt(out.gasUsed), gasRefund: BigInt(out.gasRefunded), returnValue: hexToBytes(out.output), logs: out.logs.map((l) => [hexToBytes(l.address), l.topics.map(hexToBytes), hexToBytes(l.data)]), createdAddress: out.created, sloads: out.sloads.map(([address, slot]) => ({ address: address.toLowerCase(), slot })) };
244
253
  }
@@ -262,6 +271,11 @@ export async function createTerrarium(opts = {}) {
262
271
  let recording = true;
263
272
  const tsQueue = []; // block timestamps to reuse during replay (determinism)
264
273
  let persister = null, persistKey = null, persistTimer = null;
274
+ // incremental persistence: blocks are stored in chunks of PERSIST_CHUNK under `<key>:b<i>`; `dirtyFrom` is the lowest block
275
+ // index changed since the last save (Infinity: none), `persistedChunks` how many chunk keys the store holds
276
+ const PERSIST_CHUNK = 64;
277
+ let dirtyFrom = Infinity, persistedChunks = 0;
278
+ const markDirty = (index) => { if (index < dirtyFrom) dirtyFrom = index; };
265
279
  const dealtSlots = new Map(); // token -> balance slot discovered by probing
266
280
  const impersonated = new Set();
267
281
  const exclusive = createLock();
@@ -273,7 +287,7 @@ export async function createTerrarium(opts = {}) {
273
287
 
274
288
  function pushBlock(header, txHashes, receipts) {
275
289
  const b = { ...header, transactions: txHashes, receipts };
276
- blocks.push(b);
290
+ markDirty(blocks.length); blocks.push(b);
277
291
  return b;
278
292
  }
279
293
  const stateRootOf = async () => (stateMode === 'merkle' ? bytesToHex(await sm.getStateRoot()) : (opts.stateRoot ?? ZERO32));
@@ -425,40 +439,43 @@ export async function createTerrarium(opts = {}) {
425
439
 
426
440
  /** Execute a hypothetical tx with a given gas limit on a rollback — real transaction semantics (intrinsic gas,
427
441
  * 63/64 rule for sub-calls, refunds), no state change. */
428
- async function simulateTx(p, gasLimit) {
429
- const from = getAddress(p.from ?? accounts[0].address);
430
- const acct = (await sm.getAccount(createAddressFromString(from))) ?? new Account();
431
- const tx = impersonatedTx({ chainId: BigInt(chainId), nonce: acct.nonce, maxFeePerGas: baseFee * 2n, maxPriorityFeePerGas: 1n, gasLimit, to: p.to ?? undefined, value: p.value ? hexToBigInt(p.value) : 0n, data: p.data ?? p.input ?? '0x' }, from);
432
- return withRollback(() => exec({ tx, block: pendingBlock(), flags: { skipNonce: true, skipBalance: true } }));
433
- }
434
- /** geth/anvil-style estimation: one full run, an optimistic 64/63 probe, then binary search if needed. */
442
+ /** reth-style estimation, run inside the wasm engine in ONE call: a run at the cap (a revert there is the answer),
443
+ * the optimistic (used + refunded + stipend) · 64/63 probe, then a bisection that starts near 3× the gas used and stops
444
+ * within 1.5 %. The runs share a read cache and commit nothing, so the state manager is never touched. */
435
445
  async function estimateGas(p) {
436
446
  const cap = p.gas ? hexToBigInt(p.gas) : gasLimit;
437
- const first = await simulateTx(p, cap);
438
- if (!first.success) throw revertError(first);
439
- const ok = async (g) => { try { const r = await simulateTx(p, g); return r.success; } catch { return false; } };
440
- let lo = first.gasUsed - 1n, hi = cap;
441
- const optimistic = ((first.gasUsed + first.gasRefund) * 64n) / 63n + 1n; // usually exact
442
- if (optimistic < hi && (await ok(optimistic))) hi = optimistic;
443
- while (lo + 1n < hi) { // shrink to the minimum that succeeds
444
- if (hi - lo <= hi / 64n) break; // 1.5 % tolerance like geth
445
- const mid = (lo + hi) / 2n;
446
- if (await ok(mid)) hi = mid; else lo = mid;
447
- }
448
- return hi;
447
+ const from = getAddress(p.from ?? accounts[0].address);
448
+ const acct = (await sm.getAccount(createAddressFromString(from))) ?? new Account();
449
+ const tx = impersonatedTx({ chainId: BigInt(chainId), nonce: acct.nonce, maxFeePerGas: baseFee * 2n, maxPriorityFeePerGas: 1n, gasLimit: cap, to: p.to ?? undefined, value: p.value ? hexToBigInt(p.value) : 0n, data: p.data ?? p.input ?? '0x' }, from);
450
+ const out = await revmCall(revm.estimate, revmRequest({ tx, block: pendingBlock(), flags: { skipNonce: true, skipBalance: true } }));
451
+ stats.runs += out.runs;
452
+ if (!out.success) throw revertError({ error: out.reason, returnValue: hexToBytes(out.output), gasUsed: BigInt(out.gasUsed) });
453
+ return BigInt(out.gas);
449
454
  }
450
455
 
451
456
  /** Transactions newest first, as an explorer lists them: the RPC tx merged with its receipt, a `status` word, the
452
457
  * revert reason and data of a failed one, and the block timestamp. Pending (interval mining) come first. */
458
+ // the counts are polled (the dev bar's status, twice a second): recomputed only when the chain changed
459
+ let txCounts = { key: '', failed: 0 };
460
+ function countTxs() {
461
+ const key = `${txs.size}:${pending.length}:${blocks.length}`;
462
+ if (txCounts.key !== key) { let failed = 0; for (const t of txs.values()) if (t.receipt && t.receipt.status !== '0x1') failed++; txCounts = { key, failed }; }
463
+ return { total: txs.size, pending: pending.length, failed: txCounts.failed };
464
+ }
465
+ function describeTx(t) {
466
+ const mined = !!t.receipt, n = mined ? hexToBigInt(t.receipt.blockNumber) : null;
467
+ const b = mined ? blocks[blocks.length - 1 - Number(latest().number - n)] ?? blocks.find((b) => BigInt(b.number) === n) : null;
468
+ const status = !mined ? 'pending' : t.receipt.status === '0x1' ? 'success' : t.dropped ? 'dropped' : 'reverted';
469
+ return { ...t.rpc, status, receipt: t.receipt, timestamp: b && BigInt(b.number) === n ? hex(b.timestamp) : null, error: t.error ?? null, revertData: t.revertData ?? null };
470
+ }
453
471
  function listTransactions({ limit = 50, before } = {}) {
454
- const all = [...txs.values()].reverse().map((t) => {
455
- const mined = !!t.receipt, n = mined ? hexToBigInt(t.receipt.blockNumber) : null;
456
- const b = mined ? blocks.find((b) => BigInt(b.number) === n) : null;
457
- const status = !mined ? 'pending' : t.receipt.status === '0x1' ? 'success' : t.dropped ? 'dropped' : 'reverted';
458
- return { ...t.rpc, status, receipt: t.receipt, timestamp: b ? hex(b.timestamp) : null, error: t.error ?? null, revertData: t.revertData ?? null };
459
- });
460
- const from = before ? all.findIndex((t) => t.hash === before) + 1 : 0;
461
- return { total: all.length, pending: all.filter((t) => t.status === 'pending').length, failed: all.filter((t) => t.status === 'reverted' || t.status === 'dropped').length, transactions: all.slice(from, from + Math.max(0, limit)) };
472
+ const all = [...txs.values()], out = []; // only the requested page is built: newest first, after `before` if given
473
+ let started = !before;
474
+ for (let i = all.length - 1; i >= 0 && out.length < Math.max(0, limit); i--) {
475
+ if (!started) { if (all[i].rpc.hash === before) started = true; continue; }
476
+ out.push(describeTx(all[i]));
477
+ }
478
+ return { ...countTxs(), transactions: out };
462
479
  }
463
480
 
464
481
  // ---- RPC formatting ----------------------------------------------------------------------------
@@ -607,7 +624,7 @@ export async function createTerrarium(opts = {}) {
607
624
  const i = snapshots.findIndex((s) => s.id === id); if (i < 0) return false;
608
625
  let target;
609
626
  while (snapshots.length > i) { target = snapshots.pop(); await sm.revert(); }
610
- blocks.length = target.blocksLen; journal.length = target.journalLen; pending.length = 0;
627
+ markDirty(target.blocksLen); blocks.length = target.blocksLen; journal.length = target.journalLen; pending.length = 0;
611
628
  timeOffset = target.timeOffset; nextTimestamp = target.nextTimestamp; baseFee = target.baseFee; // the clock is state too
612
629
  const head = latest().number;
613
630
  for (const [h, t] of txs) if (!t.receipt || hexToBigInt(t.receipt.blockNumber) > head) txs.delete(h);
@@ -744,7 +761,9 @@ export async function createTerrarium(opts = {}) {
744
761
  // ---- persistence: dump the diff, restore it, or replay the journal -------------------------------
745
762
  const serBlock = (b) => ({ ...b, number: hex(b.number), timestamp: hex(b.timestamp), gasLimit: hex(b.gasLimit), gasUsed: hex(b.gasUsed), baseFeePerGas: hex(b.baseFeePerGas) });
746
763
  const deserBlock = (b) => ({ ...b, number: hexToBigInt(b.number), timestamp: hexToBigInt(b.timestamp), gasLimit: hexToBigInt(b.gasLimit), gasUsed: hexToBigInt(b.gasUsed), baseFeePerGas: hexToBigInt(b.baseFeePerGas) });
747
- async function dumpState() {
764
+ async function dumpState() { const core = await dumpCore(); return { ...core, chain: { ...core.chain, blocks: blocks.map(serBlock) } }; }
765
+ /** everything but the blocks (the part the incremental save rewrites every time) */
766
+ async function dumpCore() {
748
767
  const keepFrom = latest().number - BigInt(opts.persist?.maxTxBlocks ?? 2000);
749
768
  const accountsOut = {};
750
769
  for (const a of touched.accounts) { const acct = await sm.getAccount(createAddressFromString(a)); accountsOut[a] = acct ? { nonce: hex(acct.nonce), balance: hex(acct.balance), codeHash: bytesToHex(acct.codeHash) } : null; }
@@ -753,7 +772,7 @@ export async function createTerrarium(opts = {}) {
753
772
  const remote = sm.remote ? { accounts: Object.fromEntries([...sm.remote.accounts].map(([a, acct]) => [a, acct ? { nonce: hex(acct.nonce), balance: hex(acct.balance), codeHash: bytesToHex(acct.codeHash) } : null])), code: Object.fromEntries([...sm.remote.code].map(([a, c]) => [a, bytesToHex(c)])), storage: Object.fromEntries([...sm.remote.storage].map(([k, v]) => [k, bytesToHex(v)])) } : undefined;
754
773
  return { version: 1, chainId, savedAt: Date.now(), state: { accounts: accountsOut, code: codeOut, storage: storageOut }, remote,
755
774
  // tx bodies: only the most recent blocks' worth (like a pruned node), and without their logs (rebuilt from block logs on load)
756
- chain: { blocks: blocks.map(serBlock), txs: Object.fromEntries([...txs].filter(([, t]) => !t.receipt || hexToBigInt(t.receipt.blockNumber) >= keepFrom).map(([h, t]) => [h, { rpc: t.rpc, receipt: t.receipt ? { ...t.receipt, logs: undefined } : null, error: t.error, revertData: t.revertData, dropped: t.dropped }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
775
+ chain: { txs: Object.fromEntries([...txs].filter(([, t]) => !t.receipt || hexToBigInt(t.receipt.blockNumber) >= keepFrom).map(([h, t]) => [h, { rpc: t.rpc, receipt: t.receipt ? { ...t.receipt, logs: undefined } : null, error: t.error, revertData: t.revertData, dropped: t.dropped }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
757
776
  journal: { entries: journal, timestamps: blocks.slice(1).map((b) => hex(b.timestamp)) } };
758
777
  }
759
778
  async function seedState({ accounts: accs = {}, code = {}, storage = {} }) {
@@ -767,7 +786,7 @@ export async function createTerrarium(opts = {}) {
767
786
  for (const [k, v] of Object.entries(dump.remote.storage)) { const [a, slot] = k.split('_'); await sm.putStorage(createAddressFromString(a), hexToBytes(slot), hexToBytes(v)); }
768
787
  }
769
788
  await seedState(dump.state);
770
- blocks.length = 0; for (const b of dump.chain.blocks) blocks.push(deserBlock(b));
789
+ markDirty(0); blocks.length = 0; for (const b of dump.chain.blocks) blocks.push(deserBlock(b));
771
790
  txs.clear(); for (const [h, t] of Object.entries(dump.chain.txs)) { if (t.receipt) t.receipt.logs = []; txs.set(h, t); }
772
791
  for (const b of blocks) for (const l of b.logs) { const t = txs.get(l.transactionHash); if (t?.receipt) t.receipt.logs.push(l); }
773
792
  timeOffset = hexToBigInt(dump.chain.timeOffset); baseFee = hexToBigInt(dump.chain.baseFee); mining = dump.chain.mining;
@@ -780,16 +799,46 @@ export async function createTerrarium(opts = {}) {
780
799
  recording = false; tsQueue.push(...(j.timestamps ?? []).map(hexToBigInt));
781
800
  try { for (const e of j.entries) await dispatch(e); } finally { recording = true; tsQueue.length = 0; }
782
801
  }
802
+ /** write what changed: the dirty block chunks, chunks past the head removed (a revert), then the core with the block count.
803
+ * Chunks first, core last, so a crash in between leaves a core that never points past what the store holds. */
804
+ async function persistNow() {
805
+ if (!persister) return;
806
+ const key = persistKey, store = persister;
807
+ const nChunks = Math.ceil(blocks.length / PERSIST_CHUNK);
808
+ const firstDirty = dirtyFrom === Infinity ? nChunks : Math.floor(Math.min(dirtyFrom, blocks.length) / PERSIST_CHUNK);
809
+ for (let i = firstDirty; i < nChunks; i++) await store.setItem(`${key}:b${i}`, JSON.stringify(blocks.slice(i * PERSIST_CHUNK, (i + 1) * PERSIST_CHUNK).map(serBlock)));
810
+ for (let i = nChunks; i < persistedChunks; i++) await store.removeItem(`${key}:b${i}`);
811
+ const core = await dumpCore();
812
+ await store.setItem(key, JSON.stringify({ ...core, chain: { ...core.chain, blockCount: blocks.length, chunk: PERSIST_CHUNK } }));
813
+ persistedChunks = nChunks; dirtyFrom = Infinity;
814
+ }
815
+ /** the persisted chain: a core + block chunks (or a whole dump, the pre-0.7 format) → a full dump, or null */
816
+ async function readPersisted(store, key) {
817
+ const raw = await store.getItem(key); if (!raw) return null;
818
+ const saved = typeof raw === 'string' ? JSON.parse(raw) : raw;
819
+ if (saved.chain?.blocks) return saved; // legacy: everything in one value
820
+ const all = [], n = Math.ceil((saved.chain?.blockCount ?? 0) / (saved.chain?.chunk ?? PERSIST_CHUNK));
821
+ for (let i = 0; i < n; i++) { const c = await store.getItem(`${key}:b${i}`); if (!c) break; all.push(...(typeof c === 'string' ? JSON.parse(c) : c)); }
822
+ return { ...saved, chain: { ...saved.chain, blocks: all.slice(0, saved.chain.blockCount) } };
823
+ }
824
+ /** remove everything this engine persisted under its key (the core and every block chunk) */
825
+ async function clearPersisted() {
826
+ if (!persister) return;
827
+ const raw = await persister.getItem(persistKey); const saved = raw ? (typeof raw === 'string' ? JSON.parse(raw) : raw) : null;
828
+ const n = Math.max(persistedChunks, Math.ceil((saved?.chain?.blockCount ?? 0) / (saved?.chain?.chunk ?? PERSIST_CHUNK)));
829
+ for (let i = 0; i < n; i++) await persister.removeItem(`${persistKey}:b${i}`);
830
+ await persister.removeItem(persistKey); persistedChunks = 0; dirtyFrom = 0;
831
+ }
783
832
  function schedulePersist() {
784
833
  if (!persister) return;
785
834
  clearTimeout(persistTimer);
786
- persistTimer = setTimeout(() => exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))).catch((e) => console.warn('terrarium persist failed', e)), opts.persist?.debounceMs ?? 50);
835
+ persistTimer = setTimeout(() => exclusive(persistNow).catch((e) => console.warn('terrarium persist failed', e)), opts.persist?.debounceMs ?? 50);
787
836
  }
788
837
  let restoredFromPersistence = false;
789
838
  if (opts.persist) {
790
839
  persister = opts.persist.storage; persistKey = opts.persist.key ?? `terrarium:${chainId}`;
791
- const saved = await persister.getItem(persistKey);
792
- if (saved) { await loadState(typeof saved === 'string' ? JSON.parse(saved) : saved); restoredFromPersistence = true; }
840
+ const saved = await readPersisted(persister, persistKey);
841
+ if (saved) { await loadState(saved); restoredFromPersistence = true; if (!saved.chain.chunk) { dirtyFrom = 0; persistedChunks = 0; } else { dirtyFrom = Infinity; persistedChunks = Math.ceil(blocks.length / PERSIST_CHUNK); } } // a legacy dump is rewritten in chunks on the next save
793
842
  }
794
843
  if (!restoredFromPersistence && opts.restore) await loadState(opts.restore); // a recorded fixture as the baseline
795
844
 
@@ -808,7 +857,9 @@ export async function createTerrarium(opts = {}) {
808
857
  slotFromLayout,
809
858
  /** Persistence: dump the diff (+ fork fixture), restore, or replay the journal onto new bytecode. */
810
859
  dumpState: () => exclusive(dumpState), loadState: (d) => exclusive(() => loadState(d)), replayJournal: (j) => exclusive(() => replayJournal(j)),
811
- get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))) : Promise.resolve(); },
860
+ get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(persistNow) : Promise.resolve(); },
861
+ /** remove this engine's persisted chain (core + block chunks) from the store */
862
+ clearPersisted: () => exclusive(clearPersisted),
812
863
  get blockNumber() { return latest().number; },
813
864
  /** transactions newest first ({ total, pending, failed, transactions }): tx + receipt + status word + revert reason/data + block timestamp */
814
865
  transactions: (o) => listTransactions(o),
@@ -137,7 +137,7 @@ export async function runScenario(input: ScenarioInput, opts: { storage?: Storag
137
137
  }) };
138
138
  });
139
139
  // reset wipes this scenario's chain (its key and its actors flag); other scenarios and the selection stay
140
- sim.addMethod('terrarium_reset', async () => { await actors.toggle(false); sim.stop(); if (storage && key) { await storage.removeItem(key); await storage.removeItem(actorsKey); } return true; });
140
+ sim.addMethod('terrarium_reset', async () => { await actors.toggle(false); if (storage && key) { await sim.clearPersisted(); await storage.removeItem(actorsKey); } sim.stop(); return true; });
141
141
  // several scenarios: the list for the selector, and the switch (stored, then the page reloads and boots the chosen one)
142
142
  sim.addMethod('terrarium_scenarios', () => ({ active: scenarioName, scenarios: list.map((s, i) => ({ name: names[i], description: s.description ?? null, persist: s.persist === false ? null : (s.persist ?? (list.length > 1 ? slug(names[i]) : 'default')) })) }));
143
143
  sim.addMethod('terrarium_selectScenario', async (name: string) => {