@terrariumlabs/core 0.3.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/LICENSE +21 -0
- package/README.md +57 -0
- package/bin/terrarium.mjs +135 -0
- package/fixtures/uniswap-v2-mainnet.json +19 -0
- package/package.json +74 -0
- package/src/bridge.ts +53 -0
- package/src/devbar.ts +69 -0
- package/src/engine.js +780 -0
- package/src/http.ts +217 -0
- package/src/inject.ts +44 -0
- package/src/scenario.ts +94 -0
- package/src/vite-plugin.d.ts +13 -0
- package/src/vite-plugin.js +33 -0
- package/src/worker-runtime.ts +76 -0
package/src/engine.js
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
// engine.js — a self-contained EVM chain that lives inside your frontend.
|
|
2
|
+
//
|
|
3
|
+
// Real EVM bytecode execution (revm compiled to WebAssembly, package `@terrariumlabs/evm`) wrapped in an EIP-1193 provider,
|
|
4
|
+
// so viem / wagmi / ethers use it exactly like a wallet + node. State (Merkle trie or lazily forked), blocks sealed with
|
|
5
|
+
// real tries and blooms, receipts + logs, filters, Anvil-style cheatcodes, event-reactive "actors", persistence
|
|
6
|
+
// (IndexedDB / any getItem-setItem store / fixtures), journal replay, fork mode with offline fixtures, and following a
|
|
7
|
+
// live chain's block numbers — all in JavaScript around the wasm engine.
|
|
8
|
+
|
|
9
|
+
import { SimpleStateManager, RPCStateManager, MerkleStateManager } from '@ethereumjs/statemanager';
|
|
10
|
+
import { createBlock, createBlockHeader, genTransactionsTrieRoot, Block } from '@ethereumjs/block';
|
|
11
|
+
import { MerklePatriciaTrie } from '@ethereumjs/mpt';
|
|
12
|
+
import { RLP } from '@ethereumjs/rlp';
|
|
13
|
+
import { Common, Hardfork, Mainnet } from '@ethereumjs/common';
|
|
14
|
+
import { createFeeMarket1559Tx, createTxFromRLP } from '@ethereumjs/tx';
|
|
15
|
+
import { Account, createAddressFromString, createAddressFromPrivateKey, hexToBytes, bytesToHex, KECCAK256_NULL } from '@ethereumjs/util';
|
|
16
|
+
import { BaseError as ViemBaseError, keccak256, numberToHex, numberToBytes, hexToBigInt, encodeFunctionData, isAddressEqual, getAddress, pad, concat, concatBytes, stringToHex } from 'viem';
|
|
17
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
18
|
+
|
|
19
|
+
// The 10 Anvil / Hardhat default test keys — every dapp dev already knows these addresses.
|
|
20
|
+
export const TEST_KEYS = [
|
|
21
|
+
'0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
|
|
22
|
+
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
|
|
23
|
+
'0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
|
|
24
|
+
'0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6',
|
|
25
|
+
'0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a',
|
|
26
|
+
'0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba',
|
|
27
|
+
'0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e',
|
|
28
|
+
'0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356',
|
|
29
|
+
'0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97',
|
|
30
|
+
'0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const GWEI = 1_000_000_000n;
|
|
34
|
+
const hex = (n) => numberToHex(BigInt(n));
|
|
35
|
+
const ZERO32 = '0x' + '00'.repeat(32);
|
|
36
|
+
const EMPTY_ROOT = '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421'; // keccak256(RLP(empty))
|
|
37
|
+
|
|
38
|
+
/** Everything that touches EVM state runs through one queue — overlapping checkpoint/revert pairs
|
|
39
|
+
* from concurrent requests would otherwise corrupt the state stack (Anvil serializes too). */
|
|
40
|
+
function createLock() {
|
|
41
|
+
let tail = Promise.resolve();
|
|
42
|
+
return (fn) => { const p = tail.then(fn, fn); tail = p.catch(() => {}); return p; };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Fork state manager that records every remote read so a session can be dumped to a fixture and
|
|
46
|
+
* replayed offline (no RPC, no rate limits, deterministic CI). */
|
|
47
|
+
class RecordingRPCStateManager extends RPCStateManager {
|
|
48
|
+
remote = { accounts: new Map(), code: new Map(), storage: new Map() };
|
|
49
|
+
/** offline: a cache miss is a bug in the fixture, not a reason to reach the network — throw a precise error instead */
|
|
50
|
+
offline = false; misses = [];
|
|
51
|
+
miss(kind, key) { this.misses.push({ kind, key }); throw new OfflineStateError(`offline fork: ${kind} ${key} is not in the fixture — record it (warm up that read) or run online`); }
|
|
52
|
+
/** Upstream bug (ethereumjs statemanager 10.1.3): RPCStateManager.commit() only commits the *account* cache,
|
|
53
|
+
* leaving the code/storage caches one checkpoint deep → a later revert() restores the wrong level.
|
|
54
|
+
* Commit all three caches, like MerkleStateManager does. */
|
|
55
|
+
async commit() { this._caches.commit(); }
|
|
56
|
+
/** public RPCs rate-limit and hiccup; retry with backoff instead of failing the whole tx */
|
|
57
|
+
async retry(fn) { let last; for (let i = 0; i < 5; i++) { try { return await fn(); } catch (e) { last = e; await new Promise((r) => setTimeout(r, 250 * 2 ** i)); } } throw last; }
|
|
58
|
+
/** Reads fetched under a checkpoint (every eth_call) vanish from the cache on revert; remote state at a fixed block never
|
|
59
|
+
* changes, so the recording is a permanent cache: answer repeats from it instead of fetching again. */
|
|
60
|
+
async getAccount(address) { if (this._caches.account.get(address) === undefined && this.remote.accounts.has(address.toString())) { const a = this.remote.accounts.get(address.toString()) ?? undefined; this._caches.account.put(address, a); return a; } if (this.offline && this._caches.account.get(address) === undefined) this.miss('account', address.toString()); if (globalThis.process?.env?.TERRARIUM_DEBUG && this._caches.account.get(address) === undefined) console.log('[remote] account', address.toString(), new Error().stack.split('\n').slice(2, 7).map((l) => l.trim()).join(' <- ')); const a = await this.retry(() => super.getAccount(address)); this.remote.accounts.set(address.toString(), a ?? null); return a; }
|
|
61
|
+
/** an account the cache knows does not exist has no code and no storage: answer without a round trip (the two engines
|
|
62
|
+
* ask different questions about such addresses — revm loads the account, ethereumjs the code — and a fixture recorded
|
|
63
|
+
* with one must serve the other) */
|
|
64
|
+
knownAbsent(address) { const e = this._caches.account.get(address); return e !== undefined && e.accountRLP === undefined; }
|
|
65
|
+
async getCode(address) { if (this.knownAbsent(address)) return new Uint8Array(); if (this._caches.code.get(address) === undefined && this.remote.code.has(address.toString())) { const c = this.remote.code.get(address.toString()); this._caches.code.put(address, c); return c; } if (this.offline && this._caches.code.get(address) === undefined) this.miss('code', address.toString()); const c = await this.retry(() => super.getCode(address)); this.remote.code.set(address.toString(), c); return c; }
|
|
66
|
+
async getStorage(address, key) { if (this.knownAbsent(address) && this._caches.storage.get(address, key) === undefined) return new Uint8Array(); const rk = `${address.toString()}_${bytesToHex(key)}`; if (this._caches.storage.get(address, key) === undefined && this.remote.storage.has(rk)) { const v = this.remote.storage.get(rk); this._caches.storage.put(address, key, v); return v; } if (this.offline && this._caches.storage.get(address, key) === undefined) this.miss('storage', `${address.toString()}:${bytesToHex(key)}`); if (globalThis.process?.env?.TERRARIUM_DEBUG && this._caches.storage.get(address, key) === undefined) console.log('[remote] storage', address.toString(), bytesToHex(key)); const v = await this.retry(() => super.getStorage(address, key)); this.remote.storage.set(`${address.toString()}_${bytesToHex(key)}`, v); return v; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const STATE_CHANGING = new Set(['eth_sendTransaction', 'eth_sendRawTransaction', 'evm_mine', 'anvil_mine', 'hardhat_mine', 'evm_setNextBlockTimestamp', 'anvil_setNextBlockTimestamp', 'evm_increaseTime', 'anvil_increaseTime', 'evm_setAutomine', 'anvil_setAutomine', 'anvil_setBalance', 'hardhat_setBalance', 'anvil_setCode', 'hardhat_setCode', 'anvil_setNonce', 'hardhat_setNonce', 'anvil_setStorageAt', 'hardhat_setStorageAt', 'anvil_impersonateAccount', 'hardhat_impersonateAccount', 'anvil_stopImpersonatingAccount', 'hardhat_stopImpersonatingAccount', 'anvil_setNextBlockBaseFeePerGas', 'hardhat_setNextBlockBaseFeePerGas', 'sim_deal', 'sim_setState']);
|
|
70
|
+
const pad32 = (h) => pad(typeof h === 'bigint' ? numberToHex(h, { size: 32 }) : h, { size: 32 });
|
|
71
|
+
|
|
72
|
+
/** small deterministic PRNG (mulberry32) so scripted actors are reproducible when a seed is given */
|
|
73
|
+
function mulberry32(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
|
|
74
|
+
|
|
75
|
+
/** Persister backed by IndexedDB (works in Workers, no 5 MB quota). Same getItem/setItem shape as localStorage, async. */
|
|
76
|
+
export function indexedDBStorage(dbName = 'terrarium', storeName = 'kv') {
|
|
77
|
+
const open = () => new Promise((res, rej) => { const r = indexedDB.open(dbName, 1); r.onupgradeneeded = () => r.result.createObjectStore(storeName); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
|
|
78
|
+
const run = async (mode, fn) => { const db = await open(); try { return await new Promise((res, rej) => { const tx = db.transaction(storeName, mode); const req = fn(tx.objectStore(storeName)); tx.oncomplete = () => res(req.result); tx.onerror = () => rej(tx.error); tx.onabort = () => rej(tx.error); }); } finally { db.close(); } };
|
|
79
|
+
return { getItem: (k) => run('readonly', (st) => st.get(k)).then((v) => v ?? null), setItem: (k, v) => run('readwrite', (st) => st.put(v, k)), removeItem: (k) => run('readwrite', (st) => st.delete(k)), clear: () => run('readwrite', (st) => st.clear()) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Methods a wallet (not a node) answers. They pass through the wallet gate: scripted latency / rejection. */
|
|
83
|
+
const WALLET_METHODS = new Set(['eth_sendTransaction', 'eth_requestAccounts', 'personal_sign', 'eth_signTypedData_v4', 'wallet_switchEthereumChain', 'wallet_addEthereumChain', 'wallet_requestPermissions']);
|
|
84
|
+
const SIGNING_METHODS = new Set(['eth_sendTransaction', 'personal_sign', 'eth_signTypedData_v4']);
|
|
85
|
+
|
|
86
|
+
/** EIP-1193 / JSON-RPC error: { code, message, data }. It extends viem's BaseError on purpose: viem passes its own
|
|
87
|
+
* error classes through untouched (like the errors its http transport raises), while a foreign error with an
|
|
88
|
+
* unknown code is wrapped as "unknown" and retried three times with backoff — a full second per reverted estimate. */
|
|
89
|
+
export class OfflineStateError extends Error { constructor(m) { super(m); this.name = 'OfflineStateError'; } }
|
|
90
|
+
|
|
91
|
+
class RpcError extends ViemBaseError {
|
|
92
|
+
constructor(code, message, data) { super(message, { name: 'RpcError', details: message }); this.code = code; this.data = data; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Anvil-compatible revert error, so viem decodes custom errors / reason strings unchanged. */
|
|
96
|
+
function revertError(r) {
|
|
97
|
+
const data = bytesToHex(r.returnValue ?? new Uint8Array());
|
|
98
|
+
const kind = r.error ?? 'revert';
|
|
99
|
+
if (kind === 'revert') return new RpcError(3, 'execution reverted', data);
|
|
100
|
+
return new RpcError(-32000, `execution failed: ${kind}`, data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Load the revm/WebAssembly engine (package `@terrariumlabs/evm`). In Node the wasm bytes are read from disk; in the browser
|
|
104
|
+
* the glue resolves the .wasm next to itself (Vite turns that into an asset, or a data URL in the standalone bundle). */
|
|
105
|
+
async function loadRevm(o = {}) {
|
|
106
|
+
const mod = o.module ?? (await import('@terrariumlabs/evm'));
|
|
107
|
+
if (o.wasm) await mod.default({ module_or_path: o.wasm });
|
|
108
|
+
else if (globalThis.process?.versions?.node) { const fs = await import(/* @vite-ignore */ 'node:' + 'fs'); await mod.default({ module_or_path: fs.readFileSync(new URL('./terrarium_evm_bg.wasm', import.meta.resolve('@terrariumlabs/evm'))) }); }
|
|
109
|
+
else await mod.default();
|
|
110
|
+
return mod;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function createTerrarium(opts = {}) {
|
|
114
|
+
const chainId = opts.chainId ?? 31337;
|
|
115
|
+
const hardfork = opts.hardfork ?? Hardfork.Cancun;
|
|
116
|
+
const common = new Common({ chain: { ...Mainnet, chainId, name: "sim" }, hardfork });
|
|
117
|
+
// test-mode knobs: an injectable clock (seconds), a seeded PRNG for actors, fast gas (block limit, no estimation)
|
|
118
|
+
const clock = opts.clock ?? (() => Math.floor(Date.now() / 1000));
|
|
119
|
+
const seed = opts.seed ?? (Date.now() >>> 0);
|
|
120
|
+
const random = mulberry32(seed);
|
|
121
|
+
// wallet realism: how a real wallet fails. Mutable at runtime (dev bar / tests) via terrarium_setWallet.
|
|
122
|
+
const walletKnobs = { rejectNext: 0, latencyMs: 0, receiptLagMs: 0, ...(opts.wallet ?? {}) };
|
|
123
|
+
const extensions = new Map(Object.entries(opts.methods ?? {})); // scenario-level RPC methods (terrarium_*)
|
|
124
|
+
|
|
125
|
+
// ---- state: in-memory, or lazily forked from a live RPC ------------------------------------
|
|
126
|
+
// 'merkle' (default): a real Merkle Patricia trie, so every block header carries a real stateRoot.
|
|
127
|
+
// 'simple': flat maps, a little faster, stateRoot is the `stateRoot` option (default zero). Fork mode has no local
|
|
128
|
+
// trie (remote state is unknown), so it also reports the placeholder.
|
|
129
|
+
const stateMode = opts.fork ? 'rpc' : (opts.state ?? 'merkle');
|
|
130
|
+
const stateManager = opts.fork
|
|
131
|
+
? Object.assign(new RecordingRPCStateManager({ provider: opts.fork.url ?? 'http://127.0.0.1:9/offline', blockTag: BigInt(opts.fork.blockNumber), common }), { offline: !!opts.fork.offline })
|
|
132
|
+
: stateMode === 'merkle' ? new MerkleStateManager({ common }) : new SimpleStateManager({ common });
|
|
133
|
+
|
|
134
|
+
const sm = stateManager;
|
|
135
|
+
|
|
136
|
+
// ---- execution engine: revm compiled to WebAssembly. (The @ethereumjs/vm engine was removed in 0.3.) ------------
|
|
137
|
+
if (opts.engine !== undefined && opts.engine !== 'revm') throw new Error(`engine '${opts.engine}' is not available: revm (WebAssembly) is the only execution engine since terrarium 0.3`);
|
|
138
|
+
const engine = 'revm';
|
|
139
|
+
const revm = await loadRevm(opts.revm);
|
|
140
|
+
|
|
141
|
+
// ---- write log: every local state write is recorded so dumpState() can serialize the diff -----
|
|
142
|
+
// ---- state mirror: revm reads state synchronously, the state managers are async. Every write goes
|
|
143
|
+
// through these hooks, so a checkpoint-aware mirror of everything written since boot can answer most reads
|
|
144
|
+
// synchronously; anything else is a "miss": revm aborts, the miss is loaded (from the trie, or the forked
|
|
145
|
+
// chain — recorded as usual), and the transaction is re-run. Misses converge fast: state loaded on a miss was
|
|
146
|
+
// never written since boot, so it is committed truth and lands in the base layer.
|
|
147
|
+
const touched = { accounts: new Set(), code: new Set(), storage: new Map() };
|
|
148
|
+
const KECCAK_EMPTY = bytesToHex(KECCAK256_NULL);
|
|
149
|
+
const newLayer = () => ({ accounts: new Map(), code: new Map(), storage: new Map(), cleared: new Set() });
|
|
150
|
+
const mirror = [newLayer()];
|
|
151
|
+
const top = () => mirror[mirror.length - 1];
|
|
152
|
+
const acctView = (acct) => (acct ? { balance: acct.balance, nonce: acct.nonce, codeHash: bytesToHex(acct.codeHash) } : null);
|
|
153
|
+
const pad32hex = (v) => '0x' + bytesToHex(v).slice(2).padStart(64, '0');
|
|
154
|
+
function mirrorGet(kind, key) {
|
|
155
|
+
for (let i = mirror.length - 1; i >= 0; i--) { const L = mirror[i]; if (L[kind].has(key)) return L[kind].get(key); if (kind === 'storage' && L.cleared.has(key.slice(0, 42))) return ZERO32; }
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
async function mirrorLoad(kind, key) {
|
|
159
|
+
const base = mirror[0];
|
|
160
|
+
if (globalThis.process?.env?.TERRARIUM_DEBUG) console.log('[miss]', kind, key);
|
|
161
|
+
if (kind === 'accounts') base.accounts.set(key, acctView(await sm.getAccount(createAddressFromString(key))));
|
|
162
|
+
else if (kind === 'code') base.code.set(key, bytesToHex(await sm.getCode(createAddressFromString(key))));
|
|
163
|
+
else { const [addr, slot] = key.split(':'); base.storage.set(key, pad32hex(await sm.getStorage(createAddressFromString(addr), hexToBytes(slot)))); }
|
|
164
|
+
}
|
|
165
|
+
{
|
|
166
|
+
const origPut = sm.putAccount.bind(sm); sm.putAccount = async (a, acct) => { touched.accounts.add(a.toString()); await origPut(a, acct); top().accounts.set(a.toString().toLowerCase(), acctView(acct)); };
|
|
167
|
+
const origDel = sm.deleteAccount.bind(sm); sm.deleteAccount = async (a) => { touched.accounts.add(a.toString()); await origDel(a); const k = a.toString().toLowerCase(); top().accounts.set(k, null); top().code.set(k, '0x'); top().cleared.add(k); };
|
|
168
|
+
const origMod = sm.modifyAccountFields.bind(sm); sm.modifyAccountFields = async (a, f) => { touched.accounts.add(a.toString()); await origMod(a, f); top().accounts.set(a.toString().toLowerCase(), acctView(await sm.getAccount(a))); };
|
|
169
|
+
// (no getAccount here: during a fixture restore code is seeded before accounts, and a read would go to the forked chain)
|
|
170
|
+
const origCode = sm.putCode.bind(sm); sm.putCode = async (a, v) => { touched.code.add(a.toString()); touched.accounts.add(a.toString()); await origCode(a, v); const k = a.toString().toLowerCase(); top().code.set(k, bytesToHex(v)); const cur = mirrorGet('accounts', k); if (cur) top().accounts.set(k, { ...cur, codeHash: keccak256(bytesToHex(v)) }); };
|
|
171
|
+
const origStorage = sm.putStorage.bind(sm); sm.putStorage = async (a, k, v) => { const key = a.toString(); if (!touched.storage.has(key)) touched.storage.set(key, new Set()); touched.storage.get(key).add(bytesToHex(k)); await origStorage(a, k, v); top().storage.set(`${key.toLowerCase()}:${pad32hex(k)}`, pad32hex(v)); };
|
|
172
|
+
const origClear = sm.clearStorage.bind(sm); sm.clearStorage = async (a) => { await origClear(a); const k = a.toString().toLowerCase(); top().cleared.add(k); for (const key of [...top().storage.keys()]) if (key.startsWith(k + ':')) top().storage.delete(key); };
|
|
173
|
+
const origCp = sm.checkpoint.bind(sm); sm.checkpoint = async () => { await origCp(); mirror.push(newLayer()); };
|
|
174
|
+
const origCommit = sm.commit.bind(sm); sm.commit = async () => { await origCommit(); if (mirror.length > 1) { const L = mirror.pop(), P = top(); for (const k of L.cleared) { P.cleared.add(k); for (const key of [...P.storage.keys()]) if (key.startsWith(k + ':')) P.storage.delete(key); } for (const kind of ['accounts', 'code', 'storage']) for (const [k, v] of L[kind]) P[kind].set(k, v); } };
|
|
175
|
+
const origRevert = sm.revert.bind(sm); sm.revert = async () => { await origRevert(); if (mirror.length > 1) mirror.pop(); };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- execution result shape ---------------------------------------------------------------------------------
|
|
179
|
+
// { success, error, gasUsed, gasRefund, returnValue: Uint8Array, logs: [[addr, topics[], data]] (bytes), createdAddress, sloads: [{address, slot}] }
|
|
180
|
+
// Log bloom (yellow paper, 2048 bits): for each of the first three 16-bit words of keccak256(x), the low 11 bits index a bit.
|
|
181
|
+
const BLOOM_BYTES = 256;
|
|
182
|
+
function bloomAdd(bits, data) { const h = keccak256(data, 'bytes'); for (let i = 0; i < 3; i++) { const loc = ((h[2 * i] << 8) | h[2 * i + 1]) & 2047; bits[BLOOM_BYTES - 1 - (loc >> 3)] |= 1 << (loc & 7); } }
|
|
183
|
+
const bloomOf = (logs) => { const bits = new Uint8Array(BLOOM_BYTES); for (const [addr, topics] of logs) { bloomAdd(bits, addr); for (const t of topics) bloomAdd(bits, t); } return bits; };
|
|
184
|
+
const bloomOr = (into, other) => { for (let i = 0; i < BLOOM_BYTES; i++) into[i] |= other[i]; return into; };
|
|
185
|
+
/** EIP-2718 typed receipt: txType || RLP([status, cumulativeGasUsed, logsBloom, logs]) (EIP-658 status, not a state root). */
|
|
186
|
+
const encodeReceipt = (r, txType) => { const body = RLP.encode([r.status ? Uint8Array.of(1) : new Uint8Array(), r.cumulativeBlockGasUsed === 0n ? new Uint8Array() : numberToBytes(r.cumulativeBlockGasUsed), r.bitvector, r.logs]); return txType === 0 ? body : concatBytes([Uint8Array.of(txType), body]); };
|
|
187
|
+
const msgOf = (tx) => ({ from: tx.getSenderAddress().toString(), to: tx.to ? tx.to.toString() : null, value: tx.value, data: bytesToHex(tx.data), gasLimit: tx.gasLimit, gasPrice: tx.maxFeePerGas ?? tx.gasPrice ?? 0n, priorityFee: tx.maxPriorityFeePerGas ?? tx.gasPrice ?? 0n, nonce: tx.nonce });
|
|
188
|
+
const misses = [];
|
|
189
|
+
const stats = { runs: 0, rounds: 0, wasmMs: 0 };
|
|
190
|
+
// Without a fork, the mirror has seen every write since genesis: something it has never seen simply does not exist
|
|
191
|
+
// (account) or is zero (slot). No round trip, no re-run. With a fork the truth may be remote: load it (recorded).
|
|
192
|
+
const local = stateMode !== 'rpc';
|
|
193
|
+
const revmHost = {
|
|
194
|
+
account(address) {
|
|
195
|
+
const key = address.toLowerCase(); let a = mirrorGet('accounts', key);
|
|
196
|
+
if (a === undefined) {
|
|
197
|
+
if (local) { mirror[0].accounts.set(key, null); a = null; }
|
|
198
|
+
else {
|
|
199
|
+
// fork mode. A DELEGATECALL target (proxy implementation) only needs its code, which the JS engine reads without
|
|
200
|
+
// ever loading the account — so recorded fixtures have the code but not the account. Synthesize it rather than
|
|
201
|
+
// fetch: balance/nonce of an implementation contract are irrelevant to the call, and offline replay stays offline.
|
|
202
|
+
const code = mirrorGet('code', key);
|
|
203
|
+
if (code !== undefined && code !== '0x') { a = { balance: 0n, nonce: 1n, codeHash: keccak256(code) }; mirror[0].accounts.set(key, a); }
|
|
204
|
+
else { misses.push(() => mirrorLoad('accounts', key)); throw { missing: true }; }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
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 }; } }
|
|
210
|
+
return { balance: hex(a.balance), nonce: hex(a.nonce), codeHash: a.codeHash, code };
|
|
211
|
+
},
|
|
212
|
+
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 }; },
|
|
213
|
+
blockHash(n) { return blocks.find((b) => b.number === BigInt(Math.trunc(n)))?.hash ?? ZERO32; },
|
|
214
|
+
};
|
|
215
|
+
async function applyRevmState(changes) {
|
|
216
|
+
for (const c of changes) {
|
|
217
|
+
const a = createAddressFromString(c.address);
|
|
218
|
+
if (c.deleted) { await sm.deleteAccount(a); continue; }
|
|
219
|
+
if (c.code) await sm.putCode(a, hexToBytes(c.code));
|
|
220
|
+
await sm.modifyAccountFields(a, { balance: hexToBigInt(c.balance), nonce: hexToBigInt(c.nonce) });
|
|
221
|
+
for (const [slot, value] of c.storage) await sm.putStorage(a, hexToBytes(slot), hexToBytes(value));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function execRevm({ tx, msg, block, flags = {} }) {
|
|
225
|
+
let m = tx ? msgOf(tx) : { ...msg, gasPrice: 0n, priorityFee: 0n, nonce: 0n };
|
|
226
|
+
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
|
+
block: { number: hex(block.header.number), timestamp: hex(block.header.timestamp), gasLimit: hex(block.header.gasLimit), baseFee: hex(block.header.baseFeePerGas ?? 0n) },
|
|
229
|
+
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
|
+
for (let round = 0; ; round++) {
|
|
232
|
+
misses.length = 0; stats.rounds++;
|
|
233
|
+
let out;
|
|
234
|
+
const t0 = Date.now();
|
|
235
|
+
try { out = JSON.parse(revm.run(revmHost, req)); stats.wasmMs += Date.now() - t0; }
|
|
236
|
+
catch (e) {
|
|
237
|
+
stats.wasmMs += Date.now() - t0;
|
|
238
|
+
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
|
+
const message = String(e?.message ?? e);
|
|
240
|
+
throw new Error(message.startsWith('invalid:') ? message : `revm: ${message}`);
|
|
241
|
+
}
|
|
242
|
+
await applyRevmState(out.state);
|
|
243
|
+
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
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const exec = execRevm;
|
|
247
|
+
|
|
248
|
+
// ---- chain bookkeeping ----------------------------------------------------------------------
|
|
249
|
+
const blocks = []; // index = block number - genesisNumber
|
|
250
|
+
const txs = new Map(); // hash -> { tx, rpc, receipt, blockNumber }
|
|
251
|
+
const pending = []; // mempool (in order)
|
|
252
|
+
const filters = new Map();
|
|
253
|
+
const logListeners = []; // "actor" hooks
|
|
254
|
+
const emitter = new Map(); // EIP-1193 events
|
|
255
|
+
let nextFilterId = 1;
|
|
256
|
+
let baseFee = opts.baseFeePerGas ?? GWEI;
|
|
257
|
+
let gasLimit = opts.blockGasLimit ?? 30_000_000n;
|
|
258
|
+
let timeOffset = 0n; // evm_increaseTime
|
|
259
|
+
let nextTimestamp = null; // evm_setNextBlockTimestamp
|
|
260
|
+
let mining = opts.mining ?? { mode: 'auto' };
|
|
261
|
+
const journal = []; // state-changing RPC calls, replayable onto fresh (or new!) bytecode
|
|
262
|
+
let recording = true;
|
|
263
|
+
const tsQueue = []; // block timestamps to reuse during replay (determinism)
|
|
264
|
+
let persister = null, persistKey = null, persistTimer = null;
|
|
265
|
+
const dealtSlots = new Map(); // token -> balance slot discovered by probing
|
|
266
|
+
const impersonated = new Set();
|
|
267
|
+
const exclusive = createLock();
|
|
268
|
+
const accounts = (opts.keys ?? TEST_KEYS).map((k) => privateKeyToAccount(k));
|
|
269
|
+
const keyOf = new Map(accounts.map((a, i) => [a.address.toLowerCase(), (opts.keys ?? TEST_KEYS)[i]]));
|
|
270
|
+
|
|
271
|
+
const genesisNumber = opts.fork ? BigInt(opts.fork.blockNumber) + 1n : 0n;
|
|
272
|
+
const now = () => BigInt(clock()) + timeOffset;
|
|
273
|
+
|
|
274
|
+
function pushBlock(header, txHashes, receipts) {
|
|
275
|
+
const b = { ...header, transactions: txHashes, receipts };
|
|
276
|
+
blocks.push(b);
|
|
277
|
+
return b;
|
|
278
|
+
}
|
|
279
|
+
const stateRootOf = async () => (stateMode === 'merkle' ? bytesToHex(await sm.getStateRoot()) : (opts.stateRoot ?? ZERO32));
|
|
280
|
+
/** A real, verifiable header: real stateRoot (merkle mode), transactions trie, receipts trie and bloom. A client can
|
|
281
|
+
* recompute the hash from the RPC fields and the roots from the returned txs and receipts (test/uniswap-v2.mjs does). */
|
|
282
|
+
async function sealHeader({ number, parentHash, timestamp, gasUsed, txs, receipts }) {
|
|
283
|
+
const trie = new MerklePatriciaTrie();
|
|
284
|
+
for (const [i, r] of receipts.entries()) await trie.put(RLP.encode(i), encodeReceipt(r, txs[i].type));
|
|
285
|
+
const bloom = new Uint8Array(BLOOM_BYTES);
|
|
286
|
+
for (const r of receipts) bloomOr(bloom, r.bitvector);
|
|
287
|
+
const header = createBlockHeader({ number, parentHash, timestamp, gasLimit, gasUsed, baseFeePerGas: baseFee, coinbase: '0x0000000000000000000000000000000000000000',
|
|
288
|
+
stateRoot: await stateRootOf(), transactionsTrie: await genTransactionsTrieRoot(txs), receiptTrie: trie.root(), logsBloom: bloom }, { common, skipConsensusFormatValidation: true });
|
|
289
|
+
let size = 0; try { size = new Block(header, txs, [], [], { common, skipConsensusFormatValidation: true }).serialize().length; } catch { size = header.serialize().length; }
|
|
290
|
+
return { hash: bytesToHex(header.hash()), header: header.toJSON(), size: hex(size) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// fund test accounts, then seal a real genesis over that state
|
|
294
|
+
for (const a of accounts) await sm.putAccount(createAddressFromString(a.address), new Account(0n, 10_000n * 10n ** 18n));
|
|
295
|
+
{
|
|
296
|
+
const ts = now();
|
|
297
|
+
const sealed = await sealHeader({ number: genesisNumber, parentHash: ZERO32, timestamp: ts, gasUsed: 0n, txs: [], receipts: [] });
|
|
298
|
+
pushBlock({ number: genesisNumber, ...sealed, parentHash: ZERO32, timestamp: ts, baseFeePerGas: baseFee, gasLimit, gasUsed: 0n, logs: [] }, [], []);
|
|
299
|
+
}
|
|
300
|
+
const latest = () => blocks[blocks.length - 1];
|
|
301
|
+
|
|
302
|
+
const nextTimestampFor = (parent) => nextTimestamp ?? (parent.timestamp + 1n > now() ? parent.timestamp + 1n : now());
|
|
303
|
+
/** The block a tx sent right now would land in. eth_estimateGas MUST simulate against it, not against `latest`:
|
|
304
|
+
* a contract that branches on block.timestamp (Uniswap's price accumulators write two extra slots when time has
|
|
305
|
+
* passed since the last trade) costs more gas in the next block than in the current one — estimate against the
|
|
306
|
+
* current one and the real tx runs out of gas. geth and Anvil estimate on the pending block; so do we. */
|
|
307
|
+
function pendingBlock() { const parent = latest(); return execBlock({ number: parent.number + 1n, parentHash: parent.hash, timestamp: tsQueue.length ? tsQueue[0] : nextTimestampFor(parent), baseFeePerGas: baseFee, gasLimit, gasUsed: 0n }); }
|
|
308
|
+
function execBlock(header) {
|
|
309
|
+
return createBlock({ header: { number: header.number, timestamp: header.timestamp, gasLimit: header.gasLimit, baseFeePerGas: header.baseFeePerGas, parentHash: header.parentHash, coinbase: '0x0000000000000000000000000000000000000000' } }, { common, skipConsensusFormatValidation: true, freeze: false });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---- transaction plumbing --------------------------------------------------------------------
|
|
313
|
+
async function buildTx(p) {
|
|
314
|
+
const from = getAddress(p.from);
|
|
315
|
+
const fromAddr = createAddressFromString(from);
|
|
316
|
+
const acct = (await sm.getAccount(fromAddr)) ?? new Account();
|
|
317
|
+
const data = {
|
|
318
|
+
chainId: BigInt(chainId),
|
|
319
|
+
nonce: p.nonce !== undefined ? hexToBigInt(p.nonce) : acct.nonce,
|
|
320
|
+
maxFeePerGas: p.maxFeePerGas ? hexToBigInt(p.maxFeePerGas) : (p.gasPrice ? hexToBigInt(p.gasPrice) : baseFee * 2n),
|
|
321
|
+
maxPriorityFeePerGas: p.maxPriorityFeePerGas ? hexToBigInt(p.maxPriorityFeePerGas) : 1n,
|
|
322
|
+
gasLimit: p.gas ? hexToBigInt(p.gas) : 0n,
|
|
323
|
+
to: p.to ?? undefined,
|
|
324
|
+
value: p.value ? hexToBigInt(p.value) : 0n,
|
|
325
|
+
data: p.data ?? p.input ?? '0x',
|
|
326
|
+
};
|
|
327
|
+
if (data.gasLimit === 0n) data.gasLimit = opts.gasEstimation === 'fast' ? gasLimit : await estimateGas({ ...p, from });
|
|
328
|
+
const key = keyOf.get(from.toLowerCase());
|
|
329
|
+
if (key) return createFeeMarket1559Tx(data, { common }).sign(hexToBytes(key));
|
|
330
|
+
if (!impersonated.has(from.toLowerCase()) && !opts.impersonateAll) throw new RpcError(-32000, `no key for ${from}; call anvil_impersonateAccount first`);
|
|
331
|
+
return impersonatedTx(data, from);
|
|
332
|
+
}
|
|
333
|
+
/** A tx whose sender we dictate (impersonation). Like Anvil, it carries a fake signature that encodes the sender
|
|
334
|
+
* (r = from address), so it serializes, hashes and sits in the transactions trie like any other tx. */
|
|
335
|
+
function impersonatedTx(data, from) {
|
|
336
|
+
const fromAddr = createAddressFromString(from);
|
|
337
|
+
const tx = createFeeMarket1559Tx({ ...data, v: 0n, r: hexToBigInt(from), s: 1n }, { common, freeze: false });
|
|
338
|
+
tx.getSenderAddress = () => fromAddr;
|
|
339
|
+
tx.getSenderPublicKey = () => new Uint8Array(64);
|
|
340
|
+
return tx;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function txToRpc(tx, hash, from) {
|
|
344
|
+
const j = tx.toJSON();
|
|
345
|
+
return { hash, nonce: j.nonce, from, to: j.to ?? null, value: j.value, gas: j.gasLimit, input: j.data, type: '0x2', chainId: hex(chainId),
|
|
346
|
+
maxFeePerGas: j.maxFeePerGas, maxPriorityFeePerGas: j.maxPriorityFeePerGas, gasPrice: j.maxFeePerGas, accessList: [], v: j.v ?? '0x0', r: j.r ?? '0x0', s: j.s ?? '0x0',
|
|
347
|
+
blockHash: null, blockNumber: null, transactionIndex: null };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function mine(count = 1) {
|
|
351
|
+
for (let i = 0; i < count; i++) {
|
|
352
|
+
const parent = latest();
|
|
353
|
+
const ts = tsQueue.length ? tsQueue.shift() : nextTimestampFor(parent);
|
|
354
|
+
nextTimestamp = null;
|
|
355
|
+
const header = { number: parent.number + 1n, parentHash: parent.hash, timestamp: ts, baseFeePerGas: baseFee, gasLimit, gasUsed: 0n };
|
|
356
|
+
const block = execBlock(header);
|
|
357
|
+
const batch = pending.splice(0, pending.length);
|
|
358
|
+
const receipts = [], hashes = [], blockLogs = [], sealTxs = [], sealReceipts = [];
|
|
359
|
+
let cumulative = 0n, logIndex = 0;
|
|
360
|
+
for (let idx = 0; idx < batch.length; idx++) {
|
|
361
|
+
const entry = batch[idx];
|
|
362
|
+
let r;
|
|
363
|
+
try {
|
|
364
|
+
r = await exec({ tx: entry.tx, block, flags: { blockGasUsed: cumulative } });
|
|
365
|
+
} catch (e) { // invalid tx (nonce too low, insufficient funds...): a node would drop it silently and the dapp
|
|
366
|
+
// would wait for a receipt forever. Record a failed receipt instead so waiters resolve and the reason is visible.
|
|
367
|
+
const t = txs.get(entry.hash); t.error = String(e.message);
|
|
368
|
+
receipts.push({ transactionHash: entry.hash, transactionIndex: hex(idx), from: entry.from, to: entry.rpc.to, cumulativeGasUsed: hex(cumulative), gasUsed: '0x0', effectiveGasPrice: hex(baseFee), contractAddress: null, logs: [], logsBloom: '0x' + '00'.repeat(256), status: '0x0', type: '0x2', droppedReason: t.error });
|
|
369
|
+
hashes.push(entry.hash); sealTxs.push(entry.tx); sealReceipts.push({ status: 0, cumulativeBlockGasUsed: cumulative, bitvector: new Uint8Array(256), logs: [] }); continue;
|
|
370
|
+
}
|
|
371
|
+
cumulative += r.gasUsed;
|
|
372
|
+
if (globalThis.process?.env?.TERRARIUM_DEBUG && !r.success) console.log('[tx reverted]', entry.hash, r.error, bytesToHex(r.returnValue), 'gasUsed', r.gasUsed, 'gasLimit', entry.tx.gasLimit);
|
|
373
|
+
const bloom = bloomOf(r.logs);
|
|
374
|
+
const logs = r.logs.map(([addr, topics, data]) => ({ address: bytesToHex(addr), topics: topics.map(bytesToHex), data: bytesToHex(data), blockNumber: hex(header.number), transactionHash: entry.hash, transactionIndex: hex(idx), logIndex: hex(logIndex++), removed: false }));
|
|
375
|
+
const receipt = { transactionHash: entry.hash, transactionIndex: hex(idx), from: entry.from, to: entry.rpc.to, cumulativeGasUsed: hex(cumulative), gasUsed: hex(r.gasUsed), effectiveGasPrice: hex(entry.tx.maxPriorityFeePerGas + baseFee < entry.tx.maxFeePerGas ? entry.tx.maxPriorityFeePerGas + baseFee : entry.tx.maxFeePerGas),
|
|
376
|
+
contractAddress: r.createdAddress, logs, logsBloom: bytesToHex(bloom), status: r.success ? '0x1' : '0x0', type: '0x2' };
|
|
377
|
+
receipts.push(receipt); hashes.push(entry.hash); blockLogs.push(...logs); sealTxs.push(entry.tx); sealReceipts.push({ status: r.success ? 1 : 0, cumulativeBlockGasUsed: cumulative, bitvector: bloom, logs: r.logs });
|
|
378
|
+
}
|
|
379
|
+
header.gasUsed = cumulative;
|
|
380
|
+
Object.assign(header, await sealHeader({ number: header.number, parentHash: header.parentHash, timestamp: header.timestamp, gasUsed: cumulative, txs: sealTxs, receipts: sealReceipts }));
|
|
381
|
+
for (const r of receipts) { r.blockHash = header.hash; r.blockNumber = hex(header.number); for (const l of r.logs) l.blockHash = header.hash; const t = txs.get(r.transactionHash); t.receipt = r; t.minedAt = Date.now(); t.rpc = { ...t.rpc, blockHash: header.hash, blockNumber: hex(header.number), transactionIndex: r.transactionIndex }; }
|
|
382
|
+
pushBlock({ ...header, logs: blockLogs }, hashes, receipts);
|
|
383
|
+
schedulePersist();
|
|
384
|
+
emit('message', { type: 'eth_subscription', data: { subscription: '0x1', result: rpcBlock(latest(), false) } });
|
|
385
|
+
// actors: let scripts react to what just happened (after the block is final)
|
|
386
|
+
for (const log of blockLogs) for (const l of logListeners) if (matchLog(log, l.filter)) queueMicrotask(() => l.handler(log, { blockNumber: header.number }));
|
|
387
|
+
}
|
|
388
|
+
return latest();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function submit(tx, from) {
|
|
392
|
+
const hash = bytesToHex(tx.hash());
|
|
393
|
+
txs.set(hash, { tx, rpc: txToRpc(tx, hash, from), receipt: null });
|
|
394
|
+
pending.push({ tx, hash, from, rpc: txToRpc(tx, hash, from) });
|
|
395
|
+
if (mining.mode === 'auto') await mine(1);
|
|
396
|
+
return hash;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function withRollback(fn) {
|
|
400
|
+
await sm.checkpoint();
|
|
401
|
+
try { return await fn(); } finally { await sm.revert(); }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** geth-style state override set: { [addr]: { balance, nonce, code, state, stateDiff } } — "what if" reads. */
|
|
405
|
+
async function applyOverrides(overrides) {
|
|
406
|
+
for (const [addr, o] of Object.entries(overrides ?? {})) {
|
|
407
|
+
const a = createAddressFromString(addr);
|
|
408
|
+
if (o.code) await sm.putCode(a, hexToBytes(o.code));
|
|
409
|
+
if (o.balance !== undefined || o.nonce !== undefined) await sm.modifyAccountFields(a, { ...(o.balance !== undefined && { balance: hexToBigInt(o.balance) }), ...(o.nonce !== undefined && { nonce: hexToBigInt(o.nonce) }) });
|
|
410
|
+
if (o.state) { await sm.clearStorage(a); for (const [k, v] of Object.entries(o.state)) await sm.putStorage(a, hexToBytes(pad32(k)), hexToBytes(pad32(v))); }
|
|
411
|
+
if (o.stateDiff) for (const [k, v] of Object.entries(o.stateDiff)) await sm.putStorage(a, hexToBytes(pad32(k)), hexToBytes(pad32(v)));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function call(p, blockTag, overrides) {
|
|
416
|
+
const block = blockTag === 'pending' ? pendingBlock() : execBlock(latest());
|
|
417
|
+
return withRollback(async () => {
|
|
418
|
+
await applyOverrides(overrides);
|
|
419
|
+
const r = await exec({ msg: { from: p.from ?? accounts[0].address, to: p.to ?? null, data: p.data ?? p.input ?? '0x', value: p.value ? hexToBigInt(p.value) : 0n, gasLimit: p.gas ? hexToBigInt(p.gas) : gasLimit }, block });
|
|
420
|
+
if (!r.success) throw revertError(r);
|
|
421
|
+
return bytesToHex(r.returnValue);
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Execute a hypothetical tx with a given gas limit on a rollback — real transaction semantics (intrinsic gas,
|
|
426
|
+
* 63/64 rule for sub-calls, refunds), no state change. */
|
|
427
|
+
async function simulateTx(p, gasLimit) {
|
|
428
|
+
const from = getAddress(p.from ?? accounts[0].address);
|
|
429
|
+
const acct = (await sm.getAccount(createAddressFromString(from))) ?? new Account();
|
|
430
|
+
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);
|
|
431
|
+
return withRollback(() => exec({ tx, block: pendingBlock(), flags: { skipNonce: true, skipBalance: true } }));
|
|
432
|
+
}
|
|
433
|
+
/** geth/anvil-style estimation: one full run, an optimistic 64/63 probe, then binary search if needed. */
|
|
434
|
+
async function estimateGas(p) {
|
|
435
|
+
const cap = p.gas ? hexToBigInt(p.gas) : gasLimit;
|
|
436
|
+
const first = await simulateTx(p, cap);
|
|
437
|
+
if (!first.success) throw revertError(first);
|
|
438
|
+
const ok = async (g) => { try { const r = await simulateTx(p, g); return r.success; } catch { return false; } };
|
|
439
|
+
let lo = first.gasUsed - 1n, hi = cap;
|
|
440
|
+
const optimistic = ((first.gasUsed + first.gasRefund) * 64n) / 63n + 1n; // usually exact
|
|
441
|
+
if (optimistic < hi && (await ok(optimistic))) hi = optimistic;
|
|
442
|
+
while (lo + 1n < hi) { // shrink to the minimum that succeeds
|
|
443
|
+
if (hi - lo <= hi / 64n) break; // 1.5 % tolerance like geth
|
|
444
|
+
const mid = (lo + hi) / 2n;
|
|
445
|
+
if (await ok(mid)) hi = mid; else lo = mid;
|
|
446
|
+
}
|
|
447
|
+
return hi;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---- RPC formatting ----------------------------------------------------------------------------
|
|
451
|
+
function rpcBlock(b, full) {
|
|
452
|
+
const transactions = full ? b.transactions.map((h) => txs.get(h).rpc) : b.transactions;
|
|
453
|
+
const h = b.header; // sealed header (blocks restored from a pre-0.2 dump have none: zero roots, as before)
|
|
454
|
+
if (!h) return { number: hex(b.number), hash: b.hash, parentHash: b.parentHash, nonce: '0x0000000000000000', sha3Uncles: ZERO32, logsBloom: '0x' + '00'.repeat(256), transactionsRoot: ZERO32, stateRoot: ZERO32, receiptsRoot: ZERO32, miner: '0x0000000000000000000000000000000000000000', difficulty: '0x0', totalDifficulty: '0x0', extraData: '0x', size: '0x400', gasLimit: hex(b.gasLimit), gasUsed: hex(b.gasUsed), timestamp: hex(b.timestamp), baseFeePerGas: hex(b.baseFeePerGas), mixHash: ZERO32, uncles: [], withdrawals: [], withdrawalsRoot: EMPTY_ROOT, blobGasUsed: '0x0', excessBlobGas: '0x0', parentBeaconBlockRoot: ZERO32, transactions };
|
|
455
|
+
return { number: h.number, hash: b.hash, parentHash: h.parentHash, nonce: h.nonce, sha3Uncles: h.uncleHash, logsBloom: h.logsBloom, transactionsRoot: h.transactionsTrie, stateRoot: h.stateRoot, receiptsRoot: h.receiptTrie, miner: h.coinbase, difficulty: h.difficulty, totalDifficulty: '0x0', extraData: h.extraData, size: b.size ?? '0x0', gasLimit: h.gasLimit, gasUsed: h.gasUsed, timestamp: h.timestamp, baseFeePerGas: h.baseFeePerGas, mixHash: h.mixHash, uncles: [], withdrawals: [], withdrawalsRoot: h.withdrawalsRoot ?? EMPTY_ROOT, blobGasUsed: h.blobGasUsed ?? '0x0', excessBlobGas: h.excessBlobGas ?? '0x0', parentBeaconBlockRoot: h.parentBeaconBlockRoot ?? ZERO32, transactions };
|
|
456
|
+
}
|
|
457
|
+
/** The block the next transaction lands in, as geth/Anvil report `pending`: real next number and timestamp, no hash.
|
|
458
|
+
* A dapp that derives deadlines from it is correct on an idle chain (where `latest` may be hours old) and when the
|
|
459
|
+
* dev bar has shifted the clock. */
|
|
460
|
+
function rpcPendingBlock(full) {
|
|
461
|
+
const h = pendingBlock().header, parent = latest();
|
|
462
|
+
return { number: hex(h.number), hash: null, parentHash: parent.hash, nonce: '0x0000000000000000', sha3Uncles: '0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347', logsBloom: '0x' + '00'.repeat(256), transactionsRoot: EMPTY_ROOT, stateRoot: parent.header?.stateRoot ?? ZERO32, receiptsRoot: EMPTY_ROOT, miner: '0x0000000000000000000000000000000000000000', difficulty: '0x0', totalDifficulty: '0x0', extraData: '0x', size: '0x0', gasLimit: hex(gasLimit), gasUsed: '0x0', timestamp: hex(h.timestamp), baseFeePerGas: hex(baseFee), mixHash: ZERO32, uncles: [], withdrawals: [], withdrawalsRoot: EMPTY_ROOT, blobGasUsed: '0x0', excessBlobGas: '0x0', parentBeaconBlockRoot: ZERO32, transactions: pending.map((p) => (full ? p.rpc : p.hash)) };
|
|
463
|
+
}
|
|
464
|
+
function blockByTag(tag) {
|
|
465
|
+
if (tag === undefined || tag === 'latest' || tag === 'pending' || tag === 'safe' || tag === 'finalized') return latest();
|
|
466
|
+
if (tag === 'earliest') return blocks[0];
|
|
467
|
+
if (typeof tag === 'object' && tag.blockHash) return blocks.find((b) => b.hash === tag.blockHash);
|
|
468
|
+
const n = hexToBigInt(typeof tag === 'object' ? tag.blockNumber : tag);
|
|
469
|
+
return blocks[Number(n - genesisNumber)];
|
|
470
|
+
}
|
|
471
|
+
function matchLog(log, f) {
|
|
472
|
+
if (f.address) { const list = Array.isArray(f.address) ? f.address : [f.address]; if (!list.some((a) => isAddressEqual(a, log.address))) return false; }
|
|
473
|
+
if (f.topics) for (let i = 0; i < f.topics.length; i++) { const t = f.topics[i]; if (t == null) continue; const opts = Array.isArray(t) ? t : [t]; if (!opts.some((x) => x?.toLowerCase() === log.topics[i]?.toLowerCase())) return false; }
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
function resolveNumber(tag, dflt) {
|
|
477
|
+
if (tag === undefined || tag === null) return dflt;
|
|
478
|
+
if (typeof tag === 'string' && tag.startsWith('0x')) return hexToBigInt(tag);
|
|
479
|
+
return blockByTag(tag)?.number ?? dflt;
|
|
480
|
+
}
|
|
481
|
+
function getLogs(f) {
|
|
482
|
+
if (f.blockHash) return (blocks.find((b) => b.hash === f.blockHash)?.logs ?? []).filter((l) => matchLog(l, f));
|
|
483
|
+
const from = resolveNumber(f.fromBlock, genesisNumber);
|
|
484
|
+
const to = resolveNumber(f.toBlock, latest().number);
|
|
485
|
+
const out = [];
|
|
486
|
+
for (const b of blocks) if (b.number >= from && b.number <= to) for (const l of b.logs) if (matchLog(l, f)) out.push(l);
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---- EIP-1193 events ------------------------------------------------------------------------------
|
|
491
|
+
function emit(ev, payload) { for (const fn of emitter.get(ev) ?? []) fn(payload); }
|
|
492
|
+
/** A real wallet is slow and sometimes says no. Runs *outside* the state lock so node reads keep flowing. */
|
|
493
|
+
async function walletGate(method) {
|
|
494
|
+
if (!WALLET_METHODS.has(method)) return;
|
|
495
|
+
if (walletKnobs.latencyMs > 0) await new Promise((r) => setTimeout(r, walletKnobs.latencyMs));
|
|
496
|
+
if (walletKnobs.rejectNext > 0 && SIGNING_METHODS.has(method)) { walletKnobs.rejectNext--; throw new RpcError(4001, 'User rejected the request.'); }
|
|
497
|
+
}
|
|
498
|
+
const eip1193 = {
|
|
499
|
+
on(ev, fn) { if (!emitter.has(ev)) emitter.set(ev, new Set()); emitter.get(ev).add(fn); return eip1193; },
|
|
500
|
+
removeListener(ev, fn) { emitter.get(ev)?.delete(fn); return eip1193; },
|
|
501
|
+
// extension methods (terrarium_*, from scenarios) run OUTSIDE the state lock: they orchestrate other RPC calls, each of
|
|
502
|
+
// which takes the lock itself. Running them inside would deadlock on their first inner request.
|
|
503
|
+
request(args) { const ext = extensions.get(args.method); if (ext) return Promise.resolve().then(() => ext(...(args.params ?? []))); return walletGate(args.method).then(() => exclusive(() => handle(args))); },
|
|
504
|
+
};
|
|
505
|
+
/** The same chain seen through a node RPC instead of a wallet: no accounts, no signing. What a dapp's read
|
|
506
|
+
* transport talks to in production, so reads and writes can be tested as two different endpoints. */
|
|
507
|
+
const nodeProvider = {
|
|
508
|
+
on: eip1193.on, removeListener: eip1193.removeListener,
|
|
509
|
+
request(args) {
|
|
510
|
+
if (args.method === 'eth_accounts') return Promise.resolve([]);
|
|
511
|
+
if (WALLET_METHODS.has(args.method)) return Promise.reject(new RpcError(4100, `${args.method}: this endpoint is a node, not a wallet`));
|
|
512
|
+
const ext = extensions.get(args.method); if (ext) return Promise.resolve().then(() => ext(...(args.params ?? [])));
|
|
513
|
+
return exclusive(() => handle(args));
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
async function handle(req) {
|
|
517
|
+
const result = await dispatch(req);
|
|
518
|
+
if (recording && STATE_CHANGING.has(req.method)) { journal.push({ method: req.method, params: req.params ?? [] }); schedulePersist(); }
|
|
519
|
+
return result;
|
|
520
|
+
}
|
|
521
|
+
async function dispatch({ method, params = [] }) {
|
|
522
|
+
switch (method) {
|
|
523
|
+
// --- node -------------------------------------------------------------------------------
|
|
524
|
+
case 'eth_chainId': return hex(chainId);
|
|
525
|
+
case 'net_version': return String(chainId);
|
|
526
|
+
case 'web3_clientVersion': return '@terrariumlabs/core/0.3.0';
|
|
527
|
+
case 'eth_syncing': return false;
|
|
528
|
+
case 'eth_blockNumber': return hex(latest().number);
|
|
529
|
+
case 'eth_getBlockByNumber': { if (params[0] === 'pending') return rpcPendingBlock(!!params[1]); const b = blockByTag(params[0]); return b ? rpcBlock(b, !!params[1]) : null; }
|
|
530
|
+
case 'eth_getBlockByHash': { const b = blocks.find((x) => x.hash === params[0]); return b ? rpcBlock(b, !!params[1]) : null; }
|
|
531
|
+
case 'eth_getBalance': return hex(((await sm.getAccount(createAddressFromString(params[0]))) ?? new Account()).balance);
|
|
532
|
+
case 'eth_getTransactionCount': return hex(((await sm.getAccount(createAddressFromString(params[0]))) ?? new Account()).nonce);
|
|
533
|
+
case 'eth_getCode': return bytesToHex(await sm.getCode(createAddressFromString(params[0])));
|
|
534
|
+
case 'eth_getStorageAt': { const v = await sm.getStorage(createAddressFromString(params[0]), hexToBytes(numberToHex(hexToBigInt(params[1]), { size: 32 }))); return '0x' + bytesToHex(v).slice(2).padStart(64, '0'); }
|
|
535
|
+
case 'eth_gasPrice': return hex(baseFee + 1n);
|
|
536
|
+
case 'eth_maxPriorityFeePerGas': return '0x1';
|
|
537
|
+
case 'eth_feeHistory': { const n = Number(hexToBigInt(params[0])); const b = blockByTag(params[1]); const fees = Array(n + 1).fill(hex(baseFee)); const oldest = b.number - BigInt(n) + 1n; return { oldestBlock: hex(oldest < genesisNumber ? genesisNumber : oldest), baseFeePerGas: fees, gasUsedRatio: Array(n).fill(0.1), baseFeePerBlobGas: Array(n + 1).fill('0x1'), blobGasUsedRatio: Array(n).fill(0), reward: params[2] ? Array(n).fill(params[2].map(() => '0x1')) : undefined }; }
|
|
538
|
+
case 'eth_call': return call(params[0], params[1], params[2]);
|
|
539
|
+
case 'eth_estimateGas': return hex(await estimateGas(params[0]));
|
|
540
|
+
case 'eth_sendRawTransaction': { const tx = createTxFromRLP(hexToBytes(params[0]), { common }); return submit(tx, tx.getSenderAddress().toString()); }
|
|
541
|
+
case 'eth_getTransactionReceipt': { const t = txs.get(params[0]); if (!t?.receipt) return null; if (walletKnobs.receiptLagMs > 0 && Date.now() - (t.minedAt ?? 0) < walletKnobs.receiptLagMs) return null; return t.receipt; }
|
|
542
|
+
case 'eth_getTransactionByHash': return txs.get(params[0])?.rpc ?? null;
|
|
543
|
+
case 'eth_getLogs': return getLogs(params[0] ?? {});
|
|
544
|
+
case 'eth_newFilter': filters.set(hex(nextFilterId), { type: 'logs', f: params[0] ?? {}, cursor: latest().number + 1n }); return hex(nextFilterId++);
|
|
545
|
+
case 'eth_newBlockFilter': filters.set(hex(nextFilterId), { type: 'blocks', cursor: latest().number + 1n }); return hex(nextFilterId++);
|
|
546
|
+
case 'eth_newPendingTransactionFilter': filters.set(hex(nextFilterId), { type: 'pending', cursor: latest().number + 1n }); return hex(nextFilterId++);
|
|
547
|
+
case 'eth_getFilterChanges': { const fl = filters.get(params[0]); if (!fl) throw new RpcError(-32000, 'filter not found'); const from = fl.cursor; fl.cursor = latest().number + 1n; if (fl.type === 'blocks') return blocks.filter((b) => b.number >= from).map((b) => b.hash); if (fl.type === 'pending') return []; return getLogs({ ...fl.f, fromBlock: hex(from), toBlock: 'latest' }); }
|
|
548
|
+
case 'eth_getFilterLogs': { const fl = filters.get(params[0]); return fl ? getLogs(fl.f) : []; }
|
|
549
|
+
case 'eth_uninstallFilter': return filters.delete(params[0]);
|
|
550
|
+
case 'eth_subscribe': return '0x1';
|
|
551
|
+
case 'eth_unsubscribe': return true;
|
|
552
|
+
// --- wallet -----------------------------------------------------------------------------
|
|
553
|
+
case 'eth_accounts': case 'eth_requestAccounts': return accounts.map((a) => a.address);
|
|
554
|
+
case 'wallet_switchEthereumChain': if (hexToBigInt(params[0].chainId) !== BigInt(chainId)) throw new RpcError(4902, 'Unrecognized chain ID'); return null;
|
|
555
|
+
case 'wallet_addEthereumChain': return null;
|
|
556
|
+
case 'wallet_getPermissions': case 'wallet_requestPermissions': return [{ parentCapability: 'eth_accounts' }];
|
|
557
|
+
case 'wallet_revokePermissions': return null;
|
|
558
|
+
case 'eth_sendTransaction': { const p = params[0]; const tx = await buildTx(p); return submit(tx, getAddress(p.from)); }
|
|
559
|
+
case 'personal_sign': { const acct = accounts.find((a) => isAddressEqual(a.address, params[1])); if (!acct) throw new RpcError(4100, 'unauthorized'); return acct.signMessage({ message: { raw: params[0] } }); }
|
|
560
|
+
case 'eth_signTypedData_v4': { const acct = accounts.find((a) => isAddressEqual(a.address, params[0])); if (!acct) throw new RpcError(4100, 'unauthorized'); const td = typeof params[1] === 'string' ? JSON.parse(params[1]) : params[1]; return acct.signTypedData(td); }
|
|
561
|
+
// --- cheatcodes (Anvil / Hardhat names, so viem's createTestClient({ mode: 'anvil' }) works) ---
|
|
562
|
+
case 'evm_mine': case 'anvil_mine': case 'hardhat_mine': { const n = params[0] !== undefined ? (typeof params[0] === 'object' ? Number(hexToBigInt(params[0].blocks ?? '0x1')) : Number(hexToBigInt(params[0]))) : 1; await mine(n); return '0x0'; }
|
|
563
|
+
case 'evm_setNextBlockTimestamp': case 'anvil_setNextBlockTimestamp': nextTimestamp = BigInt(params[0]); return null;
|
|
564
|
+
case 'evm_increaseTime': case 'anvil_increaseTime': timeOffset += BigInt(params[0]); return hex(timeOffset);
|
|
565
|
+
case 'evm_setAutomine': case 'anvil_setAutomine': clearInterval(intervalHandle); intervalHandle = null; mining = { mode: params[0] ? 'auto' : 'manual' }; return null;
|
|
566
|
+
case 'evm_setIntervalMining': case 'anvil_setIntervalMining': setIntervalMining(Number(params[0])); return null;
|
|
567
|
+
case 'anvil_setBalance': case 'hardhat_setBalance': await sm.modifyAccountFields(createAddressFromString(params[0]), { balance: hexToBigInt(params[1]) }); return null;
|
|
568
|
+
case 'anvil_setCode': case 'hardhat_setCode': await sm.putCode(createAddressFromString(params[0]), hexToBytes(params[1])); return null;
|
|
569
|
+
case 'anvil_setNonce': case 'hardhat_setNonce': await sm.modifyAccountFields(createAddressFromString(params[0]), { nonce: hexToBigInt(params[1]) }); return null;
|
|
570
|
+
case 'anvil_setStorageAt': case 'hardhat_setStorageAt': await sm.putStorage(createAddressFromString(params[0]), hexToBytes(numberToHex(hexToBigInt(params[1]), { size: 32 })), hexToBytes(numberToHex(hexToBigInt(params[2]), { size: 32 }))); return null;
|
|
571
|
+
case 'anvil_impersonateAccount': case 'hardhat_impersonateAccount': impersonated.add(params[0].toLowerCase()); return null;
|
|
572
|
+
case 'anvil_stopImpersonatingAccount': case 'hardhat_stopImpersonatingAccount': impersonated.delete(params[0].toLowerCase()); return null;
|
|
573
|
+
case 'anvil_setNextBlockBaseFeePerGas': case 'hardhat_setNextBlockBaseFeePerGas': baseFee = hexToBigInt(params[0]); return null;
|
|
574
|
+
case 'sim_deal': return deal(params[0], params[1], hexToBigInt(params[2]), params[3] ?? {});
|
|
575
|
+
case 'sim_setState': return setState(params[0], params[1], params[2]);
|
|
576
|
+
case 'sim_dumpState': return dumpState();
|
|
577
|
+
case 'evm_snapshot': return snapshot();
|
|
578
|
+
case 'evm_revert': return revertTo(params[0]);
|
|
579
|
+
// --- wallet realism knobs + scenario extensions -------------------------------------------
|
|
580
|
+
case 'terrarium_setWallet': Object.assign(walletKnobs, params[0] ?? {}); return { ...walletKnobs };
|
|
581
|
+
case 'terrarium_getWallet': return { ...walletKnobs };
|
|
582
|
+
default: { const ext = extensions.get(method); if (ext) return ext(...params); throw new RpcError(-32601, `Method not supported: ${method}`); }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ---- snapshots ------------------------------------------------------------------------------------
|
|
587
|
+
const snapshots = [];
|
|
588
|
+
async function snapshot() { await sm.checkpoint(); const id = hex(snapshots.length + 1); snapshots.push({ id, blocksLen: blocks.length, journalLen: journal.length, timeOffset, nextTimestamp, baseFee }); return id; }
|
|
589
|
+
/** Roll back EVM state AND everything that describes it: blocks, receipts, the journal, filter cursors, the dump. */
|
|
590
|
+
async function revertTo(id) {
|
|
591
|
+
const i = snapshots.findIndex((s) => s.id === id); if (i < 0) return false;
|
|
592
|
+
let target;
|
|
593
|
+
while (snapshots.length > i) { target = snapshots.pop(); await sm.revert(); }
|
|
594
|
+
blocks.length = target.blocksLen; journal.length = target.journalLen; pending.length = 0;
|
|
595
|
+
timeOffset = target.timeOffset; nextTimestamp = target.nextTimestamp; baseFee = target.baseFee; // the clock is state too
|
|
596
|
+
const head = latest().number;
|
|
597
|
+
for (const [h, t] of txs) if (!t.receipt || hexToBigInt(t.receipt.blockNumber) > head) txs.delete(h);
|
|
598
|
+
for (const fl of filters.values()) if (fl.cursor > head + 1n) fl.cursor = head + 1n;
|
|
599
|
+
schedulePersist();
|
|
600
|
+
emit('message', { type: 'eth_subscription', data: { subscription: '0x1', result: rpcBlock(latest(), false) } });
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ---- mining modes ---------------------------------------------------------------------------------
|
|
605
|
+
let intervalHandle = null, followHandle = null;
|
|
606
|
+
function setIntervalMining(ms) { clearInterval(intervalHandle); if (ms > 0) intervalHandle = setInterval(() => exclusive(() => mine(1)), ms); mining = { mode: ms > 0 ? 'interval' : 'manual' }; }
|
|
607
|
+
/** Mirror a live chain: each new remote block -> one local block with the same number & timestamp. */
|
|
608
|
+
async function followChain(url, { pollMs = 2000, onBlock } = {}) {
|
|
609
|
+
mining = { mode: 'follow' };
|
|
610
|
+
const rpc = async (method, params) => (await (await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) })).json()).result;
|
|
611
|
+
const tick = async () => {
|
|
612
|
+
const remote = await rpc('eth_getBlockByNumber', ['latest', false]);
|
|
613
|
+
const rn = hexToBigInt(remote.number);
|
|
614
|
+
if (rn > latest().number) {
|
|
615
|
+
// jump straight to the remote height; keep the remote timestamp so time-based UI is realistic
|
|
616
|
+
const b = await exclusive(() => { blocks[blocks.length - 1] = { ...latest(), number: rn - 1n }; nextTimestamp = hexToBigInt(remote.timestamp); return mine(1); }); // re-anchor parent, then mine
|
|
617
|
+
onBlock?.(b, remote);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
await tick();
|
|
621
|
+
followHandle = setInterval(() => tick().catch(() => {}), pollMs);
|
|
622
|
+
}
|
|
623
|
+
function stop() { clearInterval(intervalHandle); clearInterval(followHandle); clearTimeout(persistTimer); persister = null; }
|
|
624
|
+
|
|
625
|
+
// ---- fabricating state --------------------------------------------------------------------------
|
|
626
|
+
/** Run a call while recording which storage slots `target` SLOADs (Foundry's stdstore/vm.record trick). */
|
|
627
|
+
async function recordReads(target, data) {
|
|
628
|
+
const r = await withRollback(() => exec({ msg: { from: accounts[0].address, to: target, data, value: 0n, gasLimit }, block: execBlock(latest()), flags: { traceSloads: true } }));
|
|
629
|
+
return { reads: [...new Set(r.sloads.filter((x) => x.address === target.toLowerCase()).map((x) => x.slot))], result: r.success ? bytesToHex(r.returnValue) : null };
|
|
630
|
+
}
|
|
631
|
+
/** Find the slot that a view function reads *and* whose value flows to the return value. Works for
|
|
632
|
+
* Solidity & Vyper mappings, proxies (storage context = proxy), ERC-7201 namespaced storage… */
|
|
633
|
+
async function findSlot(target, data) {
|
|
634
|
+
const { reads, result } = await recordReads(target, data);
|
|
635
|
+
if (globalThis.process?.env?.TERRARIUM_DEBUG) console.log('[reads]', target, reads, result);
|
|
636
|
+
if (result === null) throw new Error(`call reverted while probing ${target}`);
|
|
637
|
+
const sentinel = 0x1234567890abcdefn;
|
|
638
|
+
for (const slot of reads) {
|
|
639
|
+
const hit = await withRollback(async () => {
|
|
640
|
+
await sm.putStorage(createAddressFromString(target), hexToBytes(slot), hexToBytes(pad32(sentinel)));
|
|
641
|
+
const r = await exec({ msg: { from: accounts[0].address, to: target, data, value: 0n, gasLimit }, block: execBlock(latest()) });
|
|
642
|
+
// a probe that lands on e.g. a proxy's implementation slot breaks the call (empty return) — not a hit
|
|
643
|
+
if (globalThis.process?.env?.TERRARIUM_DEBUG) console.log('[probe]', slot, r.error, bytesToHex(r.returnValue));
|
|
644
|
+
return r.success && r.returnValue.length >= 32 && hexToBigInt(bytesToHex(r.returnValue)) === sentinel;
|
|
645
|
+
});
|
|
646
|
+
if (hit) return { slot, current: hexToBigInt(result) };
|
|
647
|
+
}
|
|
648
|
+
throw new Error(`no direct storage slot for this read on ${target} (computed/rebasing balance?) — impersonate a holder or the minter instead`);
|
|
649
|
+
}
|
|
650
|
+
const erc20 = [{ type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }] }, { type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }];
|
|
651
|
+
/** Foundry-style `deal`: give `holder` `amount` of any ERC20 by writing its balance slot directly. */
|
|
652
|
+
async function deal(token, holder, amount, { adjustTotalSupply = true } = {}) {
|
|
653
|
+
const key = `${token.toLowerCase()}`;
|
|
654
|
+
let found = dealtSlots.get(key)?.[holder.toLowerCase()];
|
|
655
|
+
const { slot, current } = found ? { slot: found, current: hexToBigInt(await call({ to: token, data: encodeFunctionData({ abi: erc20, functionName: 'balanceOf', args: [holder] }) })) } : await findSlot(token, encodeFunctionData({ abi: erc20, functionName: 'balanceOf', args: [holder] }));
|
|
656
|
+
dealtSlots.set(key, { ...(dealtSlots.get(key) ?? {}), [holder.toLowerCase()]: slot });
|
|
657
|
+
await sm.putStorage(createAddressFromString(token), hexToBytes(slot), hexToBytes(pad32(amount)));
|
|
658
|
+
if (adjustTotalSupply) {
|
|
659
|
+
try { const ts = await findSlot(token, encodeFunctionData({ abi: erc20, functionName: 'totalSupply' })); await sm.putStorage(createAddressFromString(token), hexToBytes(ts.slot), hexToBytes(pad32(ts.current + amount - current))); } catch { /* token without a plain totalSupply slot: leave it */ }
|
|
660
|
+
}
|
|
661
|
+
schedulePersist();
|
|
662
|
+
return slot;
|
|
663
|
+
}
|
|
664
|
+
/** Compute a storage slot from solc's storageLayout, e.g. slotFromLayout(layout, ['balanceOf', alice]) or ['totalSupply']. */
|
|
665
|
+
function slotFromLayout(layout, path) {
|
|
666
|
+
const [label, ...keys] = path;
|
|
667
|
+
const v = layout.storage.find((x) => x.label === label);
|
|
668
|
+
if (!v) throw new Error(`unknown variable ${label}`);
|
|
669
|
+
let slot = BigInt(v.slot), type = layout.types[v.type];
|
|
670
|
+
for (const k of keys) {
|
|
671
|
+
if (type.encoding === 'mapping') {
|
|
672
|
+
const keyType = type.key; let enc;
|
|
673
|
+
if (keyType === 't_string_memory_ptr' || keyType === 't_string_storage' || keyType.startsWith('t_bytes_')) enc = concat([typeof k === 'string' && !k.startsWith('0x') ? stringToHex(k) : k, pad32(slot)]);
|
|
674
|
+
else enc = concat([pad32(typeof k === 'string' ? k : BigInt(k)), pad32(slot)]); // address / uintN / bytes32 / bool all left-pad to 32
|
|
675
|
+
slot = hexToBigInt(keccak256(enc)); type = layout.types[type.value];
|
|
676
|
+
} else if (type.encoding === 'dynamic_array') { slot = hexToBigInt(keccak256(pad32(slot))) + BigInt(k) * BigInt(Math.ceil(Number(layout.types[type.base].numberOfBytes) / 32)); type = layout.types[type.base]; }
|
|
677
|
+
else throw new Error(`cannot index into ${type.label}`);
|
|
678
|
+
}
|
|
679
|
+
if (v.offset && keys.length === 0 && Number(type.numberOfBytes) < 32) throw new Error(`${label} is packed (offset ${v.offset}) — write the whole slot or use sim.setStorage`);
|
|
680
|
+
return numberToHex(slot, { size: 32 });
|
|
681
|
+
}
|
|
682
|
+
/** Set state on one of *your* contracts by variable name using its artifact's storageLayout. */
|
|
683
|
+
async function setState(address, layout, values) {
|
|
684
|
+
const written = [];
|
|
685
|
+
const walk = async (path, val) => {
|
|
686
|
+
if (val !== null && typeof val === 'object' && !Array.isArray(val) && typeof val !== 'bigint') { for (const [k, v] of Object.entries(val)) await walk([...path, k], v); return; }
|
|
687
|
+
const slot = slotFromLayout(layout, path);
|
|
688
|
+
const raw = typeof val === 'boolean' ? (val ? 1n : 0n) : typeof val === 'string' && val.startsWith('0x') ? hexToBigInt(val) : BigInt(val);
|
|
689
|
+
await sm.putStorage(createAddressFromString(address), hexToBytes(slot), hexToBytes(pad32(raw)));
|
|
690
|
+
written.push({ path: path.map(String), slot });
|
|
691
|
+
};
|
|
692
|
+
for (const [k, v] of Object.entries(values)) await walk([k], v);
|
|
693
|
+
schedulePersist();
|
|
694
|
+
return written;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ---- persistence: dump the diff, restore it, or replay the journal -------------------------------
|
|
698
|
+
const serBlock = (b) => ({ ...b, number: hex(b.number), timestamp: hex(b.timestamp), gasLimit: hex(b.gasLimit), gasUsed: hex(b.gasUsed), baseFeePerGas: hex(b.baseFeePerGas) });
|
|
699
|
+
const deserBlock = (b) => ({ ...b, number: hexToBigInt(b.number), timestamp: hexToBigInt(b.timestamp), gasLimit: hexToBigInt(b.gasLimit), gasUsed: hexToBigInt(b.gasUsed), baseFeePerGas: hexToBigInt(b.baseFeePerGas) });
|
|
700
|
+
async function dumpState() {
|
|
701
|
+
const keepFrom = latest().number - BigInt(opts.persist?.maxTxBlocks ?? 2000);
|
|
702
|
+
const accountsOut = {};
|
|
703
|
+
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; }
|
|
704
|
+
const codeOut = {}; for (const a of touched.code) codeOut[a] = bytesToHex(await sm.getCode(createAddressFromString(a)));
|
|
705
|
+
const storageOut = {}; for (const [a, slots] of touched.storage) { storageOut[a] = {}; for (const s of slots) storageOut[a][s] = bytesToHex(await sm.getStorage(createAddressFromString(a), hexToBytes(s))); }
|
|
706
|
+
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;
|
|
707
|
+
return { version: 1, chainId, savedAt: Date.now(), state: { accounts: accountsOut, code: codeOut, storage: storageOut }, remote,
|
|
708
|
+
// tx bodies: only the most recent blocks' worth (like a pruned node), and without their logs (rebuilt from block logs on load)
|
|
709
|
+
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 }])), timeOffset: hex(timeOffset), baseFee: hex(baseFee), mining, impersonated: [...impersonated], dealtSlots: Object.fromEntries(dealtSlots) },
|
|
710
|
+
journal: { entries: journal, timestamps: blocks.slice(1).map((b) => hex(b.timestamp)) } };
|
|
711
|
+
}
|
|
712
|
+
async function seedState({ accounts: accs = {}, code = {}, storage = {} }) {
|
|
713
|
+
for (const [a, c] of Object.entries(code)) await sm.putCode(createAddressFromString(a), hexToBytes(c));
|
|
714
|
+
for (const [a, acct] of Object.entries(accs)) { const addr = createAddressFromString(a); if (!acct) { await sm.deleteAccount(addr); continue; } await sm.putAccount(addr, new Account(hexToBigInt(acct.nonce), hexToBigInt(acct.balance), undefined, hexToBytes(acct.codeHash))); }
|
|
715
|
+
for (const [a, slots] of Object.entries(storage)) for (const [k, v] of Object.entries(slots)) await sm.putStorage(createAddressFromString(a), hexToBytes(k), hexToBytes(v));
|
|
716
|
+
}
|
|
717
|
+
async function loadState(dump) {
|
|
718
|
+
if (dump.remote) { // fork fixture: seed remote reads first so no network is needed
|
|
719
|
+
await seedState({ accounts: dump.remote.accounts, code: dump.remote.code });
|
|
720
|
+
for (const [k, v] of Object.entries(dump.remote.storage)) { const [a, slot] = k.split('_'); await sm.putStorage(createAddressFromString(a), hexToBytes(slot), hexToBytes(v)); }
|
|
721
|
+
}
|
|
722
|
+
await seedState(dump.state);
|
|
723
|
+
blocks.length = 0; for (const b of dump.chain.blocks) blocks.push(deserBlock(b));
|
|
724
|
+
txs.clear(); for (const [h, t] of Object.entries(dump.chain.txs)) { if (t.receipt) t.receipt.logs = []; txs.set(h, t); }
|
|
725
|
+
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); }
|
|
726
|
+
timeOffset = hexToBigInt(dump.chain.timeOffset); baseFee = hexToBigInt(dump.chain.baseFee); mining = dump.chain.mining;
|
|
727
|
+
impersonated.clear(); for (const a of dump.chain.impersonated) impersonated.add(a);
|
|
728
|
+
dealtSlots.clear(); for (const [k, v] of Object.entries(dump.chain.dealtSlots ?? {})) dealtSlots.set(k, v);
|
|
729
|
+
journal.length = 0; journal.push(...(dump.journal?.entries ?? []));
|
|
730
|
+
}
|
|
731
|
+
/** Re-execute a recorded journal (e.g. after recompiling contracts) with the original block timestamps. */
|
|
732
|
+
async function replayJournal(j) {
|
|
733
|
+
recording = false; tsQueue.push(...(j.timestamps ?? []).map(hexToBigInt));
|
|
734
|
+
try { for (const e of j.entries) await dispatch(e); } finally { recording = true; tsQueue.length = 0; }
|
|
735
|
+
}
|
|
736
|
+
function schedulePersist() {
|
|
737
|
+
if (!persister) return;
|
|
738
|
+
clearTimeout(persistTimer);
|
|
739
|
+
persistTimer = setTimeout(() => exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))).catch((e) => console.warn('terrarium persist failed', e)), opts.persist?.debounceMs ?? 50);
|
|
740
|
+
}
|
|
741
|
+
let restoredFromPersistence = false;
|
|
742
|
+
if (opts.persist) {
|
|
743
|
+
persister = opts.persist.storage; persistKey = opts.persist.key ?? `terrarium:${chainId}`;
|
|
744
|
+
const saved = await persister.getItem(persistKey);
|
|
745
|
+
if (saved) { await loadState(typeof saved === 'string' ? JSON.parse(saved) : saved); restoredFromPersistence = true; }
|
|
746
|
+
}
|
|
747
|
+
if (!restoredFromPersistence && opts.restore) await loadState(opts.restore); // a recorded fixture as the baseline
|
|
748
|
+
|
|
749
|
+
// ---- public sim API (what a test / storybook / dev toolbar would use) ------------------------
|
|
750
|
+
const sim = {
|
|
751
|
+
provider: eip1193, node: nodeProvider, stateManager: sm, accounts, chainId, seed, random, wallet: walletKnobs, engine, stats, restoredFromPersistence,
|
|
752
|
+
/** offline fork mode: every state read the fixture could not answer (empty when the fixture is complete) */
|
|
753
|
+
get offlineMisses() { return sm.misses ? sm.misses.slice() : []; },
|
|
754
|
+
/** Register a scenario-level RPC method (e.g. terrarium_bot) so a dev bar can drive it through the provider alone. */
|
|
755
|
+
addMethod(name, fn) { extensions.set(name, fn); },
|
|
756
|
+
now: () => now(),
|
|
757
|
+
mine: (n) => exclusive(() => mine(n)), snapshot: () => exclusive(snapshot), revert: (id) => exclusive(() => revertTo(id)), followChain, stop,
|
|
758
|
+
/** Fabricate state. `deal` works on ANY ERC20 (slot discovery by recording SLOADs); `setState` uses your artifact's storageLayout. */
|
|
759
|
+
deal: (token, holder, amount, o) => eip1193.request({ method: 'sim_deal', params: [token, holder, hex(amount), o] }), // via RPC so it lands in the journal
|
|
760
|
+
setState: (address, layout, values) => eip1193.request({ method: 'sim_setState', params: [address, layout, JSON.parse(JSON.stringify(values, (_, v) => (typeof v === 'bigint' ? hex(v) : v)))] }),
|
|
761
|
+
slotFromLayout,
|
|
762
|
+
/** Persistence: dump the diff (+ fork fixture), restore, or replay the journal onto new bytecode. */
|
|
763
|
+
dumpState: () => exclusive(dumpState), loadState: (d) => exclusive(() => loadState(d)), replayJournal: (j) => exclusive(() => replayJournal(j)),
|
|
764
|
+
get journal() { return journal.slice(); }, flush: () => { clearTimeout(persistTimer); return persister ? exclusive(async () => persister.setItem(persistKey, JSON.stringify(await dumpState()))) : Promise.resolve(); },
|
|
765
|
+
get blockNumber() { return latest().number; },
|
|
766
|
+
/** React to on-chain events with scripted actors (keepers, oracles, other users, bridges...). */
|
|
767
|
+
onLog(filter, handler) { const l = { filter, handler }; logListeners.push(l); return () => logListeners.splice(logListeners.indexOf(l), 1); },
|
|
768
|
+
/** Cheat: send a tx "from" any address without keys (impersonation), e.g. to simulate another user. */
|
|
769
|
+
async sendAs(from, tx) { if (!impersonated.has(from.toLowerCase())) await eip1193.request({ method: 'anvil_impersonateAccount', params: [from] }); return eip1193.request({ method: 'eth_sendTransaction', params: [{ ...tx, from }] }); },
|
|
770
|
+
/** Announce as an EIP-6963 wallet so any dapp's connect modal lists "Sim Wallet" — zero app changes. */
|
|
771
|
+
announce(win = globalThis.window) {
|
|
772
|
+
if (!win) return;
|
|
773
|
+
const detail = Object.freeze({ info: { uuid: '7e44a1c0-5f0b-4c1e-9b7a-a1b2c3d4e5f6', name: 'Terrarium Wallet', icon: 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#1F6F5C"/><path d="M9 21c0-4 3-8 7-8s7 4 7 8" fill="none" stroke="#E8C547" stroke-width="2.5" stroke-linecap="round"/><circle cx="16" cy="11" r="2.5" fill="#E8C547"/></svg>'), rdns: 'dev.terrarium' }, provider: eip1193 });
|
|
774
|
+
const announce = () => win.dispatchEvent(new CustomEvent('eip6963:announceProvider', { detail }));
|
|
775
|
+
win.addEventListener('eip6963:requestProvider', announce);
|
|
776
|
+
announce();
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
return sim;
|
|
780
|
+
}
|