@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 +2 -2
- package/src/devbar.ts +3 -2
- package/src/engine.js +95 -44
- package/src/worker-runtime.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrariumlabs/core",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
209
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (
|
|
443
|
-
|
|
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()]
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
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: {
|
|
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(
|
|
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
|
|
792
|
-
if (saved) { await loadState(
|
|
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(
|
|
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),
|
package/src/worker-runtime.ts
CHANGED
|
@@ -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);
|
|
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) => {
|