From 4e451c02f297e4ecdc30dbf840465e58a01ac781 Mon Sep 17 00:00:00 2001 From: sstefdev Date: Mon, 25 May 2026 16:35:05 +0200 Subject: [PATCH 1/2] runtime: solc toolchain + ERC-20 integration test (closes #52) --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 7 + runtime/README.md | 12 +- runtime/tests/fixtures/MiniERC20.sol | 33 +++ runtime/tests/solidity.rs | 288 +++++++++++++++++++++++++++ 5 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 runtime/tests/fixtures/MiniERC20.sol create mode 100644 runtime/tests/solidity.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae3e9ed..08953ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,10 @@ jobs: uses: actions/checkout@v4 - name: Install build tools - run: sudo apt-get update && sudo apt-get install -y build-essential + # `solc` (Solidity compiler) is needed for the ERC-20 integration + # test (tests/solidity.rs). The test skips gracefully if solc is + # absent, but CI should exercise the real toolchain. + run: sudo apt-get update && sudo apt-get install -y build-essential solc - name: Build cleavec run: make -C compiler diff --git a/CHANGELOG.md b/CHANGELOG.md index d542fb8..430e06f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ Prose references a version as `v0.X.Y`; headings stay bare `[0.X.Y]`. ### Added +- Solidity toolchain integration. Real `solc`-compiled Solidity contracts now run end-to-end on the EVM engine. `runtime/tests/solidity.rs` shells out to `solc --bin --optimize` to compile `runtime/tests/fixtures/MiniERC20.sol`, deploys via `Evm::deploy` with a 32-byte ABI-encoded constructor arg (`initial supply`), then exercises `balanceOf` and `transfer` via hand-rolled ABI encoding of the standard selectors (`0x70a08231 balanceOf(address)`, `0xa9059cbb transfer(address,uint256)`, `0x18160ddd totalSupply()`). Closes #52. +- 2 new integration tests: + - `erc20_deploys_and_transfers`: deploys MiniERC20 with 1M supply to alice, asserts `totalSupply == 1M`, asserts `balanceOf(alice) == 1M` and `balanceOf(bob) == 0`, transfers 100 from alice to bob, asserts both balances post-transfer + - `erc20_transfer_reverts_when_insufficient_balance`: bob (zero balance) attempting to transfer 1 reverts with the expected revert message +- Both tests skip cleanly if `solc` is not on PATH (same pattern as the `cleavec`-dependent end-to-end test). +- CI installs `solc` in the runtime job so the Solidity tests exercise the real toolchain on every PR. +- Runtime README updated: full Solidity workflow documented; the "ERC-20 / standard Solidity contract deployment via solc toolchain" caveat removed from the "what this runtime does not yet do" list. - Real gas budget enforcement on the WASM engine (`runtime/src/lib.rs`). The v0.3 runtime tracked `gas_used` per dimension but never aborted on overflow; that caveat is gone. Two layers of metering now compose: - **Per-dimension gas budgets**: `Instance::set_gas_budget(dim, units)` sets a budget; `env.gas_consume(dim, amount)` traps the current call when `gas_used + amount > budget`. Dimensions without a budget set are unmetered. Lets chains express the multi-dimensional gas model from RFC #7 (cpu / storage / witness / etc.). - **Wasmtime fuel**: enabled in `Config::consume_fuel(true)` so every WASM instruction consumes fuel. Catches infinite loops in pure WASM that never call into a hostcall. `Instance::set_fuel(units)` sets the budget; `Instance::fuel_remaining()` reports how much is left. Default is 10 billion units (`DEFAULT_FUEL`), generous enough that existing callers see no behavior change. diff --git a/runtime/README.md b/runtime/README.md index 1ea4766..9045b2c 100644 --- a/runtime/README.md +++ b/runtime/README.md @@ -86,13 +86,22 @@ cargo run --release --bin cleave-run -- --evm 0x60005460010180600055600052602060 # storage[0] = 3 ``` +For a real Solidity contract, compile with `solc` and pass the hex bytecode the same way: + +``` +solc --bin --optimize MyToken.sol # outputs a "Binary:" hex blob +cargo run --release --bin cleave-run -- --evm 0x +``` + +`tests/solidity.rs` shows the full end-to-end pattern (compile, deploy with constructor args, call `balanceOf` / `transfer` via hand-rolled ABI encoding of the function selectors). ERC-20 transfers, balance reads, and revert-on-insufficient-balance all run against the real Solidity compiler in CI. + ## Testing ``` cargo test ``` -Five unit tests use a precompiled WASM snapshot embedded in `lib.rs` (no C toolchain needed). Two integration tests in `tests/end_to_end.rs` shell out to `../compiler/build/cleavec` to compile `examples/counter-mvp.cv` and exercise the full pipeline. The integration tests print a clear skip notice if `cleavec` is not built yet rather than failing. +Seventeen unit tests use a precompiled WASM snapshot embedded in `lib.rs` (no C toolchain needed). Two integration tests in `tests/end_to_end.rs` shell out to `../compiler/build/cleavec` to compile `examples/counter-mvp.cv` and exercise the full Cleave pipeline. Two integration tests in `tests/solidity.rs` shell out to `solc` to compile `tests/fixtures/MiniERC20.sol` and exercise the full Solidity pipeline. Integration tests print a clear skip notice if their toolchain (cleavec / solc) is not available rather than failing. ## Benchmarks @@ -124,7 +133,6 @@ These numbers reflect raw VM dispatch only. Real chain throughput will be bound - Multiple modules sharing state - Cross-module calls - **Cross-engine state sharing**: WASM modules and EVM contracts each have their own state today. A Cleave module calling a Solidity contract on the same chain (or vice versa) requires a shared state backend; that's its own future issue. -- ERC-20 / standard Solidity contract deployment via `solc` toolchain (the EVM engine accepts raw bytecode today; the toolchain layer is the next step) - JSON-RPC layer for `eth_sendRawTransaction` and friends - Integration with a consensus layer diff --git a/runtime/tests/fixtures/MiniERC20.sol b/runtime/tests/fixtures/MiniERC20.sol new file mode 100644 index 0000000..f4a5459 --- /dev/null +++ b/runtime/tests/fixtures/MiniERC20.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +// Minimal ERC-20 contract used as the smoke-test for the Solidity +// toolchain integration (issue #52). Only the surface the integration +// test exercises: balanceOf, transfer, totalSupply, and a constructor +// that mints the initial supply to the deployer. +// +// This is deliberately not full ERC-20 (no approve / transferFrom / allowance +// / events) so the bytecode stays small and the test stays focused on +// proving the Solidity-on-Cleave path works. Production ERC-20 deployments +// belong in user code, not this fixture. + +contract MiniERC20 { + mapping(address => uint256) public balances; + uint256 public totalSupply; + + constructor(uint256 initial) { + balances[msg.sender] = initial; + totalSupply = initial; + } + + function balanceOf(address who) external view returns (uint256) { + return balances[who]; + } + + function transfer(address to, uint256 amount) external returns (bool) { + require(balances[msg.sender] >= amount, "insufficient balance"); + balances[msg.sender] -= amount; + balances[to] += amount; + return true; + } +} diff --git a/runtime/tests/solidity.rs b/runtime/tests/solidity.rs new file mode 100644 index 0000000..7998e97 --- /dev/null +++ b/runtime/tests/solidity.rs @@ -0,0 +1,288 @@ +//! Solidity toolchain integration (issue #52). +//! +//! Compiles `tests/fixtures/MiniERC20.sol` with the system `solc`, +//! deploys via `Evm::deploy`, then exercises the standard ERC-20 surface +//! (balanceOf, transfer) using hand-rolled ABI encoding. Skips cleanly +//! with a printed notice if `solc` is not on the PATH. +//! +//! What this test proves: a real Solidity contract, compiled by the +//! real Solidity compiler, runs end-to-end on the EVM engine in +//! `cleave-runtime`. No precompiled bytecode constants and no +//! hand-crafted opcodes. + +use std::path::PathBuf; +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; +use cleave_runtime::evm::{Address, Bytes, U256}; +use cleave_runtime::Evm; + +// ============== environment + skip helpers ============== + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn erc20_source() -> PathBuf { + workspace_root().join("tests/fixtures/MiniERC20.sol") +} + +fn ensure_solc_or_skip(test_name: &str) -> bool { + match Command::new("solc").arg("--version").output() { + Ok(out) if out.status.success() => true, + _ => { + eprintln!( + "{test_name}: skipping; `solc` not found on PATH. \ + Install via `brew install solidity` or `apt-get install solc`." + ); + false + } + } +} + +// ============== solc shell-out ============== + +/// Compile a Solidity source file with `solc --bin --optimize` and +/// return the deployment bytecode for the named contract. +fn compile_contract(source: &PathBuf, contract_name: &str) -> Result> { + let output = Command::new("solc") + .args(["--bin", "--optimize", "--no-color"]) + .arg(source) + .output() + .context("running solc")?; + + if !output.status.success() { + return Err(anyhow!( + "solc failed:\n{}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let stdout = String::from_utf8(output.stdout).context("solc stdout was not utf-8")?; + extract_binary(&stdout, contract_name) +} + +/// Parse the textual output of `solc --bin`. The format we look for: +/// +/// ======= path/file.sol:ContractName ======= +/// Binary: +/// 608060405234801561... +fn extract_binary(stdout: &str, contract_name: &str) -> Result> { + let header_suffix = format!(":{contract_name} ======="); + enum Phase { Header, Binary, Hex } + let mut phase = Phase::Header; + for raw in stdout.lines() { + let line = raw.trim(); + match phase { + Phase::Header => { + if raw.starts_with("=======") && raw.ends_with(&header_suffix) { + phase = Phase::Binary; + } + } + Phase::Binary => { + if line.starts_with("Binary:") { + phase = Phase::Hex; + } + } + Phase::Hex => { + if !line.is_empty() { + return hex_decode(line); + } + } + } + } + Err(anyhow!( + "could not find binary for contract '{contract_name}' in solc output" + )) +} + +fn hex_decode(s: &str) -> Result> { + let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s); + if !s.len().is_multiple_of(2) { + return Err(anyhow!("hex string has odd length")); + } + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(s.len() / 2); + for chunk in bytes.chunks(2) { + let hi = hex_nibble(chunk[0])?; + let lo = hex_nibble(chunk[1])?; + out.push(hi << 4 | lo); + } + Ok(out) +} + +fn hex_nibble(c: u8) -> Result { + Ok(match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => 10 + c - b'a', + b'A'..=b'F' => 10 + c - b'A', + _ => return Err(anyhow!("invalid hex character: {:?}", c as char)), + }) +} + +// ============== ABI encoding helpers ============== +// +// Hand-rolled for the three function selectors this test uses. Each +// selector is the first 4 bytes of keccak256("name(types)"): +// +// balanceOf(address) -> 0x70a08231 +// transfer(address,uint256) -> 0xa9059cbb +// totalSupply() -> 0x18160ddd +// +// Pulling in alloy-sol-types would handle this generically but adds a +// non-trivial dep for ~30 lines of hand-rolling. Worth revisiting if +// the test surface grows. + +const SEL_BALANCE_OF: [u8; 4] = [0x70, 0xa0, 0x82, 0x31]; +const SEL_TRANSFER: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb]; +const SEL_TOTAL_SUPPLY: [u8; 4] = [0x18, 0x16, 0x0d, 0xdd]; + +/// Big-endian 32-byte word, left-padded with zeros for shorter inputs. +fn encode_u256(value: U256) -> [u8; 32] { + value.to_be_bytes() +} + +/// 32-byte word: 12 bytes of zero padding + 20-byte address. +fn encode_address(addr: Address) -> [u8; 32] { + let mut out = [0u8; 32]; + out[12..].copy_from_slice(addr.as_slice()); + out +} + +/// ABI for the deployment constructor `constructor(uint256 initial)`: +/// just the 32-byte initial supply appended to the bytecode. +fn encode_deploy_call(bytecode: Vec, initial_supply: U256) -> Vec { + let mut out = bytecode; + out.extend_from_slice(&encode_u256(initial_supply)); + out +} + +fn encode_balance_of(who: Address) -> Vec { + let mut out = Vec::with_capacity(4 + 32); + out.extend_from_slice(&SEL_BALANCE_OF); + out.extend_from_slice(&encode_address(who)); + out +} + +fn encode_transfer(to: Address, amount: U256) -> Vec { + let mut out = Vec::with_capacity(4 + 32 + 32); + out.extend_from_slice(&SEL_TRANSFER); + out.extend_from_slice(&encode_address(to)); + out.extend_from_slice(&encode_u256(amount)); + out +} + +fn encode_total_supply() -> Vec { + SEL_TOTAL_SUPPLY.to_vec() +} + +/// Decode a 32-byte big-endian uint256 return value. +fn decode_u256(bytes: &Bytes) -> U256 { + let mut padded = [0u8; 32]; + let copy_len = bytes.len().min(32); + padded[32 - copy_len..].copy_from_slice(&bytes[..copy_len]); + U256::from_be_bytes(padded) +} + +// ============== the tests ============== + +#[test] +fn erc20_deploys_and_transfers() { + if !ensure_solc_or_skip("erc20_deploys_and_transfers") { + return; + } + + // Compile the fixture. + let bytecode = compile_contract(&erc20_source(), "MiniERC20") + .expect("compile MiniERC20.sol"); + assert!(!bytecode.is_empty(), "compiled bytecode should be non-empty"); + + // Deploy with constructor arg: initial supply = 1_000_000. + let alice: Address = "0xaaaa000000000000000000000000000000000001" + .parse() + .expect("static address literal parses"); + let bob: Address = "0xbbbb000000000000000000000000000000000002" + .parse() + .expect("static address literal parses"); + + let initial_supply = U256::from(1_000_000u64); + let deploy_calldata = encode_deploy_call(bytecode, initial_supply); + + let mut evm = Evm::new(); + evm.fund(alice, U256::from(10_000_000_000_000_000_000u128)); + evm.fund(bob, U256::from(10_000_000_000_000_000_000u128)); + + let contract_addr = evm + .deploy(alice, deploy_calldata) + .expect("MiniERC20 deploys"); + + // totalSupply() should equal the constructor arg. + let supply_bytes = evm + .call(alice, contract_addr, encode_total_supply()) + .expect("totalSupply call succeeds"); + assert_eq!(decode_u256(&supply_bytes), initial_supply); + + // balanceOf(alice) should equal initial supply (alice deployed). + let alice_balance = evm + .call(alice, contract_addr, encode_balance_of(alice)) + .expect("balanceOf(alice) call succeeds"); + assert_eq!(decode_u256(&alice_balance), initial_supply); + + // balanceOf(bob) should be zero. + let bob_balance = evm + .call(alice, contract_addr, encode_balance_of(bob)) + .expect("balanceOf(bob) call succeeds"); + assert_eq!(decode_u256(&bob_balance), U256::ZERO); + + // Transfer 100 from alice to bob. + let transfer_amount = U256::from(100u64); + let transfer_result = evm + .call(alice, contract_addr, encode_transfer(bob, transfer_amount)) + .expect("transfer call succeeds"); + // transfer() returns bool; 32-byte word with the LSB = 1. + assert_eq!(decode_u256(&transfer_result), U256::from(1u64)); + + // Re-check balances. + let alice_after = evm + .call(alice, contract_addr, encode_balance_of(alice)) + .expect("balanceOf(alice) post-transfer"); + let bob_after = evm + .call(alice, contract_addr, encode_balance_of(bob)) + .expect("balanceOf(bob) post-transfer"); + assert_eq!(decode_u256(&alice_after), initial_supply - transfer_amount); + assert_eq!(decode_u256(&bob_after), transfer_amount); +} + +#[test] +fn erc20_transfer_reverts_when_insufficient_balance() { + if !ensure_solc_or_skip("erc20_transfer_reverts_when_insufficient_balance") { + return; + } + + let bytecode = compile_contract(&erc20_source(), "MiniERC20") + .expect("compile MiniERC20.sol"); + + let alice: Address = "0xaaaa000000000000000000000000000000000001" + .parse() + .unwrap(); + let bob: Address = "0xbbbb000000000000000000000000000000000002" + .parse() + .unwrap(); + + let mut evm = Evm::new(); + evm.fund(alice, U256::from(10_000_000_000_000_000_000u128)); + evm.fund(bob, U256::from(10_000_000_000_000_000_000u128)); + + let deploy_calldata = encode_deploy_call(bytecode, U256::from(100u64)); + let contract_addr = evm.deploy(alice, deploy_calldata).expect("deploy"); + + // Bob has zero balance; transferring 1 from bob should revert. + let err = evm + .call(bob, contract_addr, encode_transfer(alice, U256::from(1u64))) + .expect_err("transfer with insufficient balance should revert"); + let msg = format!("{err:#}"); + assert!( + msg.contains("revert"), + "error should indicate a revert: {msg}" + ); +} From 08d9b34504ef6b6b1ea632418f6ac5bb8272f2e0 Mon Sep 17 00:00:00 2001 From: sstefdev Date: Mon, 25 May 2026 16:37:29 +0200 Subject: [PATCH 2/2] ci: install solc from upstream release (Ubuntu apt does not ship it) --- .github/workflows/ci.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08953ba..1909723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,10 +80,20 @@ jobs: uses: actions/checkout@v4 - name: Install build tools - # `solc` (Solidity compiler) is needed for the ERC-20 integration - # test (tests/solidity.rs). The test skips gracefully if solc is - # absent, but CI should exercise the real toolchain. - run: sudo apt-get update && sudo apt-get install -y build-essential solc + run: sudo apt-get update && sudo apt-get install -y build-essential + + - name: Install solc + # Ubuntu's default repos don't ship `solc`; install the static + # Linux binary directly from the Solidity release. Version is + # pinned for reproducibility and so the Solidity integration + # test (tests/solidity.rs) sees a known compiler in CI. + run: | + set -eux + SOLC_VERSION=v0.8.35 + curl -sSL -o /tmp/solc \ + "https://github.com/ethereum/solidity/releases/download/${SOLC_VERSION}/solc-static-linux" + sudo install -m 0755 /tmp/solc /usr/local/bin/solc + solc --version - name: Build cleavec run: make -C compiler