@terrariumlabs/evm 0.5.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/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "terrarium-evm"
3
- version = "0.5.0"
3
+ version = "0.7.0"
4
4
  edition = "2021"
5
5
  description = "revm compiled to WebAssembly as a Terrarium execution backend"
6
6
  license = "MIT"
@@ -23,3 +23,4 @@ getrandom = { version = "0.2", features = ["js"] }
23
23
  opt-level = 3
24
24
  lto = true
25
25
  codegen-units = 1
26
+ panic = "abort" # no unwinding tables: smaller wasm; a panic is a trap either way in wasm
package/README.md CHANGED
@@ -13,8 +13,14 @@ It executes one transaction per call. Everything else stays in JavaScript: accou
13
13
  reverts, blocks and receipts, persistence, fork recording. The engine asks the host for what it reads and returns a
14
14
  state diff to apply:
15
15
 
16
+ Two entry points: `run(host, request)` executes one transaction and returns its state diff; `estimate(host, request)` runs
17
+ reth's gas estimation (a run at the cap, the optimistic 64/63 probe, a bisection from ~3× the gas used, within 1.5 %) in one
18
+ call against a read cache, committing nothing. Analysed bytecode is cached by code hash across calls: `host.account(address,
19
+ wantCode)` may omit `code` when `wantCode` is false (never answer `'0x'` for a contract). Built with `wasm-opt -O3` and
20
+ `panic = "abort"`: 1.29 MB, 463 KB gzipped.
21
+
16
22
  ```js
17
- import init, { run, version } from '@terrariumlabs/evm';
23
+ import init, { run, estimate, version } from '@terrariumlabs/evm';
18
24
  await init({ module_or_path: wasmBytesOrUrl });
19
25
  const result = JSON.parse(run(host, JSON.stringify({ tx, block, cfg })));
20
26
  // result: { success, reason, gasUsed, gasRefunded, output, created, logs, state, sloads }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrariumlabs/evm",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "revm compiled to WebAssembly: the fast execution backend for Terrarium",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,6 +43,6 @@
43
43
  "Cargo.toml"
44
44
  ],
45
45
  "scripts": {
46
- "build": "cargo build --release --target wasm32-unknown-unknown && wasm-bindgen --target web --out-dir pkg target/wasm32-unknown-unknown/release/terrarium_evm.wasm"
46
+ "build": "cargo build --release --target wasm32-unknown-unknown && wasm-bindgen --target web --out-dir pkg target/wasm32-unknown-unknown/release/terrarium_evm.wasm && wasm-opt -O3 --enable-bulk-memory --enable-nontrapping-float-to-int --enable-sign-ext --enable-mutable-globals -o pkg/terrarium_evm_bg.wasm pkg/terrarium_evm_bg.wasm && ls -la pkg/terrarium_evm_bg.wasm"
47
47
  }
48
48
  }
@@ -1,6 +1,14 @@
1
1
  /* tslint:disable */
2
2
  /* eslint-disable */
3
3
 
4
+ /**
5
+ * Estimate the gas for a transaction (same request shape as `run`; `tx.gasLimit` is the cap, usually the block gas
6
+ * limit). reth's algorithm, all inside wasm: one run at the cap (a failure there is the answer: the tx reverts), the
7
+ * optimistic `(used + refunded + stipend) · 64/63` probe, then bisection between `used - 1` and the best known limit,
8
+ * starting at `min(3 · used, mid)`, stopping within 1.5 %. Returns a JSON EstimateResult. Throws `missing` like `run`.
9
+ */
10
+ export function estimate(host: any, request: string): string;
11
+
4
12
  /**
5
13
  * Execute one transaction. `request` is a JSON string (RunRequest); returns a JSON string (RunResult).
6
14
  * Throws a string starting with `missing` when the host could not provide some state (re-run after fetching), and a
@@ -14,6 +22,7 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
14
22
 
15
23
  export interface InitOutput {
16
24
  readonly memory: WebAssembly.Memory;
25
+ readonly estimate: (a: any, b: number, c: number) => [number, number, number, number];
17
26
  readonly run: (a: any, b: number, c: number) => [number, number, number, number];
18
27
  readonly version: () => [number, number];
19
28
  readonly __wbindgen_malloc: (a: number, b: number) => number;
@@ -1,5 +1,35 @@
1
1
  /* @ts-self-types="./terrarium_evm.d.ts" */
2
2
 
3
+ /**
4
+ * Estimate the gas for a transaction (same request shape as `run`; `tx.gasLimit` is the cap, usually the block gas
5
+ * limit). reth's algorithm, all inside wasm: one run at the cap (a failure there is the answer: the tx reverts), the
6
+ * optimistic `(used + refunded + stipend) · 64/63` probe, then bisection between `used - 1` and the best known limit,
7
+ * starting at `min(3 · used, mid)`, stopping within 1.5 %. Returns a JSON EstimateResult. Throws `missing` like `run`.
8
+ * @param {any} host
9
+ * @param {string} request
10
+ * @returns {string}
11
+ */
12
+ export function estimate(host, request) {
13
+ let deferred3_0;
14
+ let deferred3_1;
15
+ try {
16
+ const ptr0 = passStringToWasm0(request, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
17
+ const len0 = WASM_VECTOR_LEN;
18
+ const ret = wasm.estimate(host, ptr0, len0);
19
+ var ptr2 = ret[0];
20
+ var len2 = ret[1];
21
+ if (ret[3]) {
22
+ ptr2 = 0; len2 = 0;
23
+ throw takeFromExternrefTable0(ret[2]);
24
+ }
25
+ deferred3_0 = ptr2;
26
+ deferred3_1 = len2;
27
+ return getStringFromWasm0(ptr2, len2);
28
+ } finally {
29
+ wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
30
+ }
31
+ }
32
+
3
33
  /**
4
34
  * Execute one transaction. `request` is a JSON string (RunRequest); returns a JSON string (RunResult).
5
35
  * Throws a string starting with `missing` when the host could not provide some state (re-run after fetching), and a
@@ -78,11 +108,11 @@ function __wbg_get_imports() {
78
108
  __wbg___wbindgen_throw_bb96b2010945f0bc: function(arg0, arg1) {
79
109
  throw new Error(getStringFromWasm0(arg0, arg1));
80
110
  },
81
- __wbg_account_865c9c68e728eee9: function() { return handleError(function (arg0, arg1, arg2) {
82
- const ret = arg0.account(getStringFromWasm0(arg1, arg2));
111
+ __wbg_account_56bab912b202698f: function() { return handleError(function (arg0, arg1, arg2, arg3) {
112
+ const ret = arg0.account(getStringFromWasm0(arg1, arg2), arg3 !== 0);
83
113
  return ret;
84
114
  }, arguments); },
85
- __wbg_blockHash_7e3aa4fc3b1cfb6b: function() { return handleError(function (arg0, arg1) {
115
+ __wbg_blockHash_767bc4c470457615: function() { return handleError(function (arg0, arg1) {
86
116
  const ret = arg0.blockHash(arg1);
87
117
  return ret;
88
118
  }, arguments); },
@@ -90,7 +120,7 @@ function __wbg_get_imports() {
90
120
  const ret = Reflect.get(arg0, arg1);
91
121
  return ret;
92
122
  }, arguments); },
93
- __wbg_storage_51594800c64c7b13: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) {
123
+ __wbg_storage_cd38fcce66b0ec85: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) {
94
124
  const ret = arg0.storage(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4));
95
125
  return ret;
96
126
  }, arguments); },
Binary file
@@ -1,6 +1,7 @@
1
1
  /* tslint:disable */
2
2
  /* eslint-disable */
3
3
  export const memory: WebAssembly.Memory;
4
+ export const estimate: (a: any, b: number, c: number) => [number, number, number, number];
4
5
  export const run: (a: any, b: number, c: number) => [number, number, number, number];
5
6
  export const version: () => [number, number];
6
7
  export const __wbindgen_malloc: (a: number, b: number) => number;
package/src/lib.rs CHANGED
@@ -4,6 +4,12 @@
4
4
  //! only executes: it asks the host for whatever it reads, and returns the result plus the state diff to apply.
5
5
  //! If the host cannot answer synchronously (fork mode: the slot has to be fetched from a node), it throws an error
6
6
  //! marked `missing`; execution aborts, the host fetches, and re-runs. Reads are recorded, so re-runs are exact.
7
+ //!
8
+ //! Two entry points: `run` executes one transaction and returns its state diff; `estimate` runs reth's gas estimation
9
+ //! (first run at the cap, the optimistic 64/63 probe, then a bisection that starts near 3× the gas used) entirely in
10
+ //! here, against a read cache, so the host answers each account and slot once and JavaScript makes one call.
11
+ //! Analysed bytecode is cached by code hash across calls: the host is asked for a contract's code once, not per read.
12
+ use std::cell::RefCell;
7
13
  use std::collections::HashMap;
8
14
  use std::str::FromStr;
9
15
 
@@ -17,7 +23,7 @@ use revm::interpreter::interpreter_types::{InputsTr, Jumps, StackTr};
17
23
  #[allow(unused_imports)] use StackTr as _StackTrUsed;
18
24
  use revm::interpreter::Interpreter;
19
25
  use revm::primitives::hardfork::SpecId;
20
- use revm::primitives::{Address, Bytes, TxKind, B256, U256};
26
+ use revm::primitives::{Address, Bytes, TxKind, B256, KECCAK_EMPTY, U256};
21
27
  use revm::state::{AccountInfo, Bytecode};
22
28
  use serde::{Deserialize, Serialize};
23
29
  use wasm_bindgen::prelude::*;
@@ -26,9 +32,10 @@ use wasm_bindgen::prelude::*;
26
32
  #[wasm_bindgen]
27
33
  extern "C" {
28
34
  pub type Host;
29
- /// -> null (no account) | { balance, nonce, codeHash, code } as hex strings. Throws { missing: true } to abort.
35
+ /// -> null (no account) | { balance, nonce, codeHash, code? } as hex strings. `want_code` false: the host may omit
36
+ /// `code` (this side has it cached by codeHash); a host that always includes it works too. Throws { missing: true } to abort.
30
37
  #[wasm_bindgen(method, catch)]
31
- fn account(this: &Host, address: &str) -> Result<JsValue, JsValue>;
38
+ fn account(this: &Host, address: &str, want_code: bool) -> Result<JsValue, JsValue>;
32
39
  /// -> 32-byte hex
33
40
  #[wasm_bindgen(method, catch)]
34
41
  fn storage(this: &Host, address: &str, slot: &str) -> Result<JsValue, JsValue>;
@@ -57,26 +64,44 @@ fn parse_b256(s: &str) -> Result<B256, HostError> { B256::from_str(s).map_err(|e
57
64
  fn parse_addr(s: &str) -> Result<Address, HostError> { Address::from_str(s).map_err(|e| HostError::Other(format!("bad address {s}: {e}"))) }
58
65
  fn parse_bytes(s: &str) -> Result<Bytes, HostError> { Bytes::from_str(s).map_err(|e| HostError::Other(format!("bad bytes: {e}"))) }
59
66
 
60
- /// revm's view of the world: every read goes to the host, cached for the duration of one run.
67
+ // analysed bytecode by code hash, kept across calls (a dev chain has a few dozen contracts; cleared if it ever balloons)
68
+ thread_local! { static CODE_CACHE: RefCell<HashMap<B256, Bytecode>> = RefCell::new(HashMap::new()); }
69
+ const CODE_CACHE_MAX: usize = 4096;
70
+
71
+ /// revm's view of the world: every read goes to the host, cached for the duration of one call (`run`, or a whole
72
+ /// `estimate`: its runs share the cache, and nothing is ever committed, so the host is asked once per key).
61
73
  struct HostDb<'a> { host: &'a Host, accounts: HashMap<Address, Option<AccountInfo>>, storage: HashMap<(Address, U256), U256> }
62
74
 
63
75
  impl<'a> Database for HostDb<'a> {
64
76
  type Error = HostError;
65
77
  fn basic(&mut self, address: Address) -> Result<Option<AccountInfo>, HostError> {
66
78
  if let Some(a) = self.accounts.get(&address) { return Ok(a.clone()); }
67
- let v = self.host.account(&format!("{address:?}")).map_err(js_err)?;
79
+ let key = format!("{address:?}");
80
+ let v = self.host.account(&key, false).map_err(js_err)?;
68
81
  let info = if v.is_null() || v.is_undefined() { None } else {
69
- let code = parse_bytes(&js_str(&v, "code")?)?;
70
82
  let code_hash = parse_b256(&js_str(&v, "codeHash")?)?;
71
83
  let mut info = AccountInfo::default();
72
84
  info.balance = parse_u256(&js_str(&v, "balance")?)?; info.nonce = parse_u256(&js_str(&v, "nonce")?)?.to::<u64>(); info.code_hash = code_hash;
73
- if !code.is_empty() { info.code = Some(Bytecode::new_raw(code)); } // code comes with the account: revm never needs code_by_hash
85
+ if code_hash != KECCAK_EMPTY {
86
+ let cached = CODE_CACHE.with(|c| c.borrow().get(&code_hash).cloned());
87
+ let bytecode = match cached {
88
+ Some(b) => b,
89
+ None => {
90
+ // not cached yet: take the code from this answer if the host sent it, else ask for it once
91
+ let code = match js_str(&v, "code") { Ok(s) if s.len() > 2 => s, _ => js_str(&self.host.account(&key, true).map_err(js_err)?, "code")? };
92
+ let b = Bytecode::new_raw(parse_bytes(&code)?);
93
+ CODE_CACHE.with(|c| { let mut c = c.borrow_mut(); if c.len() >= CODE_CACHE_MAX { c.clear(); } c.insert(code_hash, b.clone()); });
94
+ b
95
+ }
96
+ };
97
+ info.code = Some(bytecode); // code comes with the account: revm never needs code_by_hash
98
+ }
74
99
  Some(info)
75
100
  };
76
101
  self.accounts.insert(address, info.clone());
77
102
  Ok(info)
78
103
  }
79
- fn code_by_hash(&mut self, _code_hash: B256) -> Result<Bytecode, HostError> { Ok(Bytecode::default()) } // never reached: basic() carries the code
104
+ fn code_by_hash(&mut self, code_hash: B256) -> Result<Bytecode, HostError> { Ok(CODE_CACHE.with(|c| c.borrow().get(&code_hash).cloned()).unwrap_or_default()) }
80
105
  fn storage(&mut self, address: Address, index: U256) -> Result<U256, HostError> {
81
106
  if let Some(v) = self.storage.get(&(address, index)) { return Ok(*v); }
82
107
  let v = self.host.storage(&format!("{address:?}"), &format!("{index:#066x}")).map_err(js_err)?;
@@ -124,6 +149,11 @@ pub struct AccountOut { address: String, deleted: bool, balance: String, nonce:
124
149
  #[derive(Serialize)]
125
150
  #[serde(rename_all = "camelCase")]
126
151
  pub struct RunResult { success: bool, reason: String, gas_used: u64, gas_refunded: u64, output: String, created: Option<String>, logs: Vec<LogOut>, state: Vec<AccountOut>, sloads: Vec<(String, String)> }
152
+ /// `estimate`'s answer: the gas limit to use, how many runs it took, and the first run's outcome (a revert at the cap
153
+ /// means the transaction fails regardless of gas: `success` false, `reason` / `output` say why, like a receipt would).
154
+ #[derive(Serialize)]
155
+ #[serde(rename_all = "camelCase")]
156
+ pub struct EstimateResult { gas: u64, runs: u32, success: bool, reason: String, output: String, gas_used: u64 }
127
157
 
128
158
  fn spec_of(name: Option<&str>) -> SpecId {
129
159
  match name.map(|s| s.to_ascii_lowercase()).as_deref() { Some("prague") => SpecId::PRAGUE, Some("shanghai") => SpecId::SHANGHAI, Some("merge") | Some("paris") => SpecId::MERGE, Some("osaka") => SpecId::OSAKA, _ => SpecId::CANCUN }
@@ -143,7 +173,18 @@ pub fn run(host: &Host, request: &str) -> Result<String, JsValue> {
143
173
  run_inner(host, req).map_err(|e| JsValue::from_str(&e))
144
174
  }
145
175
 
146
- fn run_inner(host: &Host, req: RunRequest) -> Result<String, String> {
176
+ /// Estimate the gas for a transaction (same request shape as `run`; `tx.gasLimit` is the cap, usually the block gas
177
+ /// limit). reth's algorithm, all inside wasm: one run at the cap (a failure there is the answer: the tx reverts), the
178
+ /// optimistic `(used + refunded + stipend) · 64/63` probe, then bisection between `used - 1` and the best known limit,
179
+ /// starting at `min(3 · used, mid)`, stopping within 1.5 %. Returns a JSON EstimateResult. Throws `missing` like `run`.
180
+ #[wasm_bindgen]
181
+ pub fn estimate(host: &Host, request: &str) -> Result<String, JsValue> {
182
+ let req: RunRequest = serde_json::from_str(request).map_err(|e| JsValue::from_str(&format!("bad request: {e}")))?;
183
+ estimate_inner(host, req).map_err(|e| JsValue::from_str(&e))
184
+ }
185
+
186
+ /// the environments a request describes
187
+ fn envs(req: &RunRequest) -> Result<(BlockEnv, TxEnv, CfgEnv), String> {
147
188
  let h = |e: HostError| e.to_string();
148
189
  let block = BlockEnv {
149
190
  number: parse_u256(&req.block.number).map_err(h)?,
@@ -178,18 +219,26 @@ fn run_inner(host: &Host, req: RunRequest) -> Result<String, String> {
178
219
  cfg.disable_eip3607 = req.cfg.skip_eip3607; // simulations may originate from a contract address
179
220
  cfg.limit_contract_code_size = Some(usize::MAX); // allowUnlimitedContractSize, like the JS engine
180
221
  cfg.limit_contract_initcode_size = Some(usize::MAX);
222
+ Ok((block, tx, cfg))
223
+ }
224
+
225
+ /// the errors of a transact call, as the host sees them
226
+ fn evm_error(e: EVMError<HostError>) -> String {
227
+ match e {
228
+ EVMError::Database(HostError::Missing) => "missing".into(),
229
+ EVMError::Database(e) => format!("host: {e}"),
230
+ EVMError::Transaction(e) => format!("invalid: {e:?}"),
231
+ EVMError::Header(e) => format!("invalid header: {e:?}"),
232
+ e => format!("evm: {e:?}"),
233
+ }
234
+ }
181
235
 
236
+ fn run_inner(host: &Host, req: RunRequest) -> Result<String, String> {
237
+ let (block, tx, cfg) = envs(&req)?;
182
238
  let db = HostDb { host, accounts: HashMap::new(), storage: HashMap::new() };
183
239
  let ctx = Context::mainnet().with_db(db).with_block(block).with_cfg(cfg);
184
240
  let mut evm = ctx.build_mainnet_with_inspector(SloadTracer { on: req.cfg.trace_sloads, reads: Vec::new() });
185
- let res = match evm.inspect_tx(tx) {
186
- Ok(r) => r,
187
- Err(EVMError::Database(HostError::Missing)) => return Err("missing".into()),
188
- Err(EVMError::Database(e)) => return Err(format!("host: {e}")),
189
- Err(EVMError::Transaction(e)) => return Err(format!("invalid: {e:?}")),
190
- Err(EVMError::Header(e)) => return Err(format!("invalid header: {e:?}")),
191
- Err(e) => return Err(format!("evm: {e:?}")),
192
- };
241
+ let res = evm.inspect_tx(tx).map_err(evm_error)?;
193
242
  let sloads = evm.inspector.reads.iter().map(|(a, s)| (format!("{a:?}"), format!("{s:#066x}"))).collect();
194
243
 
195
244
  let (success, reason, gas, logs, output, created) = match res.result {
@@ -213,3 +262,45 @@ fn run_inner(host: &Host, req: RunRequest) -> Result<String, String> {
213
262
  };
214
263
  serde_json::to_string(&out).map_err(|e| e.to_string())
215
264
  }
265
+
266
+ /// reth's estimation constants: the error ratio the bisection stops at, and the call stipend added to the optimistic probe
267
+ const ESTIMATE_GAS_ERROR_RATIO: f64 = 0.015;
268
+ const CALL_STIPEND_GAS: u64 = 2_300;
269
+
270
+ fn estimate_inner(host: &Host, req: RunRequest) -> Result<String, String> {
271
+ let (block, tx, cfg) = envs(&req)?;
272
+ let db = HostDb { host, accounts: HashMap::new(), storage: HashMap::new() };
273
+ let ctx = Context::mainnet().with_db(db).with_block(block).with_cfg(cfg);
274
+ let mut evm = ctx.build_mainnet_with_inspector(SloadTracer::default());
275
+ let mut runs: u32 = 0;
276
+ // nothing is committed between runs, so every run starts from the same state and the read cache holds
277
+ let mut exec = |gas_limit: u64| -> Result<ExecutionResult, String> {
278
+ runs += 1;
279
+ let mut t = tx.clone(); t.gas_limit = gas_limit;
280
+ Ok(evm.inspect_tx(t).map_err(evm_error)?.result)
281
+ };
282
+ let cap = tx.gas_limit;
283
+ let first = exec(cap)?;
284
+ let (gas_used, refunded) = match &first {
285
+ ExecutionResult::Success { gas, .. } => (gas.tx_gas_used(), gas.final_refunded()),
286
+ ExecutionResult::Revert { gas, output, .. } => {
287
+ return serde_json::to_string(&EstimateResult { gas: 0, runs, success: false, reason: "revert".into(), output: format!("0x{}", hex::encode(output)), gas_used: gas.tx_gas_used() }).map_err(|e| e.to_string());
288
+ }
289
+ ExecutionResult::Halt { reason, gas, .. } => {
290
+ return serde_json::to_string(&EstimateResult { gas: 0, runs, success: false, reason: format!("{reason:?}"), output: "0x".into(), gas_used: gas.tx_gas_used() }).map_err(|e| e.to_string());
291
+ }
292
+ };
293
+ let mut highest = cap;
294
+ let mut lowest = gas_used.saturating_sub(1);
295
+ // the optimistic probe: what a call needs when every frame keeps its 1/64th
296
+ let optimistic = (gas_used + refunded + CALL_STIPEND_GAS) * 64 / 63;
297
+ if optimistic < highest { if exec(optimistic)?.is_success() { highest = optimistic; } else { lowest = optimistic; } }
298
+ // bisection, starting near 3× the gas used rather than in the middle of a 30M range (reth)
299
+ let mut mid = std::cmp::min(gas_used.saturating_mul(3), (highest + lowest) / 2);
300
+ while highest - lowest > 1 {
301
+ if ((highest - lowest) as f64) / (highest as f64) < ESTIMATE_GAS_ERROR_RATIO { break; }
302
+ if exec(mid)?.is_success() { highest = mid; } else { lowest = mid; }
303
+ mid = (highest + lowest) / 2;
304
+ }
305
+ serde_json::to_string(&EstimateResult { gas: highest, runs, success: true, reason: "ok".into(), output: "0x".into(), gas_used }).map_err(|e| e.to_string())
306
+ }