diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c6eb9c..41b61a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,9 @@ jobs: - name: Test # --locked: same rationale as Clippy above — fail loudly on lockfile drift. run: cargo test --workspace --locked + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: EVM integration tests (Anvil) + run: OCTO_EVM_TESTS=1 cargo test -p octo-evm-test-support --locked diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12d0699..8ad9a40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,20 @@ cp .env.example .env just build && just test ``` +### Optional: EVM tests (Foundry) + +The `octo-evm-test-support` crate drives a local [Anvil](https://getfoundry.sh) devnet for EVM +integration tests — the same "run against a local node, not a public testnet" story +`StellarNetwork::Standalone` gives Stellar work. These tests are skipped by default (`just test` +stays green with no Foundry installed); install Foundry and opt in with `just test-evm` if you're +working on EVM code: + +```bash +curl -L https://foundry.paradigm.xyz | bash +foundryup +just test-evm +``` + ## Before opening a PR Run the same checks CI runs: diff --git a/crates/evm-test-support/Cargo.toml b/crates/evm-test-support/Cargo.toml new file mode 100644 index 0000000..cc18b30 --- /dev/null +++ b/crates/evm-test-support/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "octo-evm-test-support" +description = "Anvil-based local EVM devnet harness for integration tests (per-test isolated instances, reorg controls, a configurable-decimals mock ERC-20)." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +reqwest.workspace = true +tokio.workspace = true +serde_json.workspace = true +thiserror.workspace = true +hex.workspace = true +sha3 = "0.10" + +[dev-dependencies] +tokio.workspace = true diff --git a/crates/evm-test-support/contracts/MockERC20.bin b/crates/evm-test-support/contracts/MockERC20.bin new file mode 100644 index 0000000..95ff4d4 --- /dev/null +++ b/crates/evm-test-support/contracts/MockERC20.bin @@ -0,0 +1 @@ +0x608060405234801562000010575f80fd5b5060405162000b2338038062000b238339810160408190526200003391620001db565b5f620000408582620002ea565b5060016200004f8482620002ea565b506002805460ff841660ff19909116179055600480546001600160a01b031916331790558015620000865762000086338262000090565b50505050620003dc565b8060035f828254620000a39190620003b6565b90915550506001600160a01b0382165f9081526005602052604081208054839290620000d1908490620003b6565b90915550506040518181526001600160a01b038316905f907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b634e487b7160e01b5f52604160045260245ffd5b5f82601f8301126200013e575f80fd5b81516001600160401b03808211156200015b576200015b6200011a565b604051601f8301601f19908116603f011681019082821181831017156200018657620001866200011a565b8160405283815260209250866020858801011115620001a3575f80fd5b5f91505b83821015620001c65785820183015181830184015290820190620001a7565b5f602085830101528094505050505092915050565b5f805f8060808587031215620001ef575f80fd5b84516001600160401b038082111562000206575f80fd5b62000214888389016200012e565b955060208701519150808211156200022a575f80fd5b5062000239878288016200012e565b935050604085015160ff8116811462000250575f80fd5b6060959095015193969295505050565b600181811c908216806200027557607f821691505b6020821081036200029457634e487b7160e01b5f52602260045260245ffd5b50919050565b601f821115620002e557805f5260205f20601f840160051c81016020851015620002c15750805b601f840160051c820191505b81811015620002e2575f8155600101620002cd565b50505b505050565b81516001600160401b038111156200030657620003066200011a565b6200031e8162000317845462000260565b846200029a565b602080601f83116001811462000354575f84156200033c5750858301515b5f19600386901b1c1916600185901b178555620003ae565b5f85815260208120601f198616915b82811015620003845788860151825594840194600190910190840162000363565b5085821015620003a257878501515f19600388901b60f8161c191681555b505060018460011b0185555b505050505050565b80820180821115620003d657634e487b7160e01b5f52601160045260245ffd5b92915050565b61073980620003ea5f395ff3fe608060405234801561000f575f80fd5b50600436106100a6575f3560e01c806340c10f191161006e57806340c10f191461013457806370a08231146101495780638da5cb5b1461016857806395d89b4114610193578063a9059cbb1461019b578063dd62ed3e146101ae575f80fd5b806306fdde03146100aa578063095ea7b3146100c857806318160ddd146100eb57806323b872dd14610102578063313ce56714610115575b5f80fd5b6100b26101d8565b6040516100bf91906105ae565b60405180910390f35b6100db6100d6366004610615565b610263565b60405190151581526020016100bf565b6100f460035481565b6040519081526020016100bf565b6100db61011036600461063d565b6102cf565b6002546101229060ff1681565b60405160ff90911681526020016100bf565b610147610142366004610615565b610394565b005b6100f4610157366004610676565b60056020525f908152604090205481565b60045461017b906001600160a01b031681565b6040516001600160a01b0390911681526020016100bf565b6100b26103f3565b6100db6101a9366004610615565b610400565b6100f46101bc366004610696565b600660209081525f928352604080842090915290825290205481565b5f80546101e4906106c7565b80601f0160208091040260200160405190810160405280929190818152602001828054610210906106c7565b801561025b5780601f106102325761010080835404028352916020019161025b565b820191905f5260205f20905b81548152906001019060200180831161023e57829003601f168201915b505050505081565b335f8181526006602090815260408083206001600160a01b038716808552925280832085905551919290917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925906102bd9086815260200190565b60405180910390a35060015b92915050565b6001600160a01b0383165f908152600660209081526040808320338452909152812054828110156103515760405162461bcd60e51b815260206004820152602160248201527f4d6f636b45524332303a20696e73756666696369656e7420616c6c6f77616e636044820152606560f81b60648201526084015b60405180910390fd5b61035b8382610713565b6001600160a01b0386165f908152600660209081526040808320338452909152902055610389858585610415565b506001949350505050565b6004546001600160a01b031633146103e55760405162461bcd60e51b815260206004820152601460248201527326b7b1b5a2a92199181d103737ba1037bbb732b960611b6044820152606401610348565b6103ef8282610528565b5050565b600180546101e4906106c7565b5f61040c338484610415565b50600192915050565b6001600160a01b0383165f9081526005602052604090205481111561047c5760405162461bcd60e51b815260206004820152601f60248201527f4d6f636b45524332303a20696e73756666696369656e742062616c616e6365006044820152606401610348565b6001600160a01b0383165f90815260056020526040812080548392906104a3908490610713565b90915550506001600160a01b0382165f90815260056020526040812080548392906104cf908490610726565b92505081905550816001600160a01b0316836001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8360405161051b91815260200190565b60405180910390a3505050565b8060035f8282546105399190610726565b90915550506001600160a01b0382165f9081526005602052604081208054839290610565908490610726565b90915550506040518181526001600160a01b038316905f907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b5f602080835283518060208501525f5b818110156105da578581018301518582016040015282016105be565b505f604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b0381168114610610575f80fd5b919050565b5f8060408385031215610626575f80fd5b61062f836105fa565b946020939093013593505050565b5f805f6060848603121561064f575f80fd5b610658846105fa565b9250610666602085016105fa565b9150604084013590509250925092565b5f60208284031215610686575f80fd5b61068f826105fa565b9392505050565b5f80604083850312156106a7575f80fd5b6106b0836105fa565b91506106be602084016105fa565b90509250929050565b600181811c908216806106db57607f821691505b6020821081036106f957634e487b7160e01b5f52602260045260245ffd5b50919050565b634e487b7160e01b5f52601160045260245ffd5b818103818111156102c9576102c96106ff565b808201808211156102c9576102c96106ff56 \ No newline at end of file diff --git a/crates/evm-test-support/contracts/MockERC20.sol b/crates/evm-test-support/contracts/MockERC20.sol new file mode 100644 index 0000000..307d899 --- /dev/null +++ b/crates/evm-test-support/contracts/MockERC20.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +// Reference source for the creation bytecode checked into `MockERC20.bin`, which +// `src/erc20.rs` embeds via `include_str!`. Cargo does not invoke solc — only `anvil` needs to be +// on PATH to run these tests, so the bytecode is precompiled rather than compiled at test time. +// +// To regenerate after editing this file: +// forge init --no-git --no-commit /tmp/mockerc20-build +// cp MockERC20.sol /tmp/mockerc20-build/src/ +// cd /tmp/mockerc20-build && forge build --optimize --optimizer-runs 200 --use 0.8.24 +// jq -r '.bytecode.object' out/MockERC20.sol/MockERC20.json > MockERC20.bin +// (no trailing newline needed; `include_str!` + `.trim()` tolerates one either way) +// Compiled with: solc 0.8.24, optimizer on (200 runs), bytecode_hash = none, cbor_metadata = false. + +/// Minimal ERC-20 with a constructor-configurable `decimals`, used only by the Octo Anvil test +/// harness to exercise decimal handling (6 / 18 / 0) against a real deployed contract. Not for +/// production use — no access control beyond `mint` being owner-only. +contract MockERC20 { + string public name; + string public symbol; + uint8 public decimals; + uint256 public totalSupply; + address public owner; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory name_, string memory symbol_, uint8 decimals_, uint256 initialSupply) { + name = name_; + symbol = symbol_; + decimals = decimals_; + owner = msg.sender; + if (initialSupply > 0) { + _mint(msg.sender, initialSupply); + } + } + + function mint(address to, uint256 amount) external { + require(msg.sender == owner, "MockERC20: not owner"); + _mint(to, amount); + } + + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + require(allowed >= amount, "MockERC20: insufficient allowance"); + allowance[from][msg.sender] = allowed - amount; + _transfer(from, to, amount); + return true; + } + + function _mint(address to, uint256 amount) internal { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + function _transfer(address from, address to, uint256 amount) internal { + require(balanceOf[from] >= amount, "MockERC20: insufficient balance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} diff --git a/crates/evm-test-support/src/abi.rs b/crates/evm-test-support/src/abi.rs new file mode 100644 index 0000000..4686f71 --- /dev/null +++ b/crates/evm-test-support/src/abi.rs @@ -0,0 +1,135 @@ +//! Minimal hand-rolled ABI encoding — just enough to construct and call the one contract this +//! harness deploys (`MockERC20`). Not a general ABI codec. + +use sha3::{Digest, Keccak256}; + +const WORD: usize = 32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Address(pub [u8; 20]); + +impl Address { + pub fn from_hex(s: &str) -> Option { + let s = s.trim_start_matches("0x"); + let bytes = hex::decode(s).ok()?; + let arr: [u8; 20] = bytes.try_into().ok()?; + Some(Address(arr)) + } + + pub fn to_hex(self) -> String { + format!("0x{}", hex::encode(self.0)) + } +} + +/// The first 4 bytes of `keccak256(signature)` — the standard Solidity function/constructor +/// selector (constructors don't actually use a selector on-chain, but the hash is a convenient +/// self-check that the crate's Keccak wiring matches known values; see the unit tests). +pub fn selector(signature: &str) -> [u8; 4] { + let mut hasher = Keccak256::new(); + hasher.update(signature.as_bytes()); + let digest = hasher.finalize(); + let mut out = [0u8; 4]; + out.copy_from_slice(&digest[..4]); + out +} + +pub fn encode_uint256(value: u128) -> [u8; 32] { + let mut word = [0u8; 32]; + word[16..].copy_from_slice(&value.to_be_bytes()); + word +} + +pub fn encode_uint8(value: u8) -> [u8; 32] { + let mut word = [0u8; 32]; + word[31] = value; + word +} + +pub fn encode_address(addr: Address) -> [u8; 32] { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(&addr.0); + word +} + +/// ABI-encodes a `string` as a standalone dynamic tail: a 32-byte length prefix followed by the +/// UTF-8 bytes, right-padded to a multiple of 32 bytes. +fn encode_string_tail(value: &str) -> Vec { + let bytes = value.as_bytes(); + let padding = (WORD - (bytes.len() % WORD)) % WORD; + let mut out = Vec::with_capacity(WORD + bytes.len() + padding); + out.extend_from_slice(&encode_uint256(bytes.len() as u128)); + out.extend_from_slice(bytes); + out.extend(std::iter::repeat_n(0u8, padding)); + out +} + +/// Encodes `constructor(string name, string symbol, uint8 decimals, uint256 initialSupply)` +/// arguments per the standard ABI head/tail layout for two dynamic + two static parameters. +pub fn encode_constructor_args( + name: &str, + symbol: &str, + decimals: u8, + initial_supply: u128, +) -> Vec { + let head_len = 4 * WORD; + let name_tail = encode_string_tail(name); + let symbol_offset = head_len + name_tail.len(); + + let mut out = Vec::new(); + out.extend_from_slice(&encode_uint256(head_len as u128)); + out.extend_from_slice(&encode_uint256(symbol_offset as u128)); + out.extend_from_slice(&encode_uint8(decimals)); + out.extend_from_slice(&encode_uint256(initial_supply)); + out.extend_from_slice(&name_tail); + out.extend_from_slice(&encode_string_tail(symbol)); + out +} + +/// Encodes a call to a two-argument `(address, uint256)` function — covers `mint` and `transfer`. +pub fn encode_address_uint256_call(signature: &str, addr: Address, amount: u128) -> Vec { + let mut out = Vec::with_capacity(4 + 2 * WORD); + out.extend_from_slice(&selector(signature)); + out.extend_from_slice(&encode_address(addr)); + out.extend_from_slice(&encode_uint256(amount)); + out +} + +/// Encodes a call to a one-argument `(address)` function — covers `balanceOf`. +pub fn encode_address_call(signature: &str, addr: Address) -> Vec { + let mut out = Vec::with_capacity(4 + WORD); + out.extend_from_slice(&selector(signature)); + out.extend_from_slice(&encode_address(addr)); + out +} + +/// Decodes a single right-aligned `uint256` return value, truncated to `u128` (sufficient for +/// harness fixture amounts). +pub fn decode_uint256(data: &[u8]) -> u128 { + let word = &data[data.len().saturating_sub(WORD)..]; + let mut buf = [0u8; 16]; + buf.copy_from_slice(&word[16..32]); + u128::from_be_bytes(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Cross-checked against `cast keccak "transfer(address,uint256)"` (Foundry) — these are also + // the well-known standard ERC-20 selectors. + #[test] + fn selector_matches_known_erc20_signatures() { + assert_eq!( + hex::encode(selector("transfer(address,uint256)")), + "a9059cbb" + ); + assert_eq!(hex::encode(selector("mint(address,uint256)")), "40c10f19"); + assert_eq!(hex::encode(selector("balanceOf(address)")), "70a08231"); + } + + #[test] + fn uint256_round_trips_through_decode() { + let encoded = encode_uint256(123_456_789); + assert_eq!(decode_uint256(&encoded), 123_456_789); + } +} diff --git a/crates/evm-test-support/src/anvil.rs b/crates/evm-test-support/src/anvil.rs new file mode 100644 index 0000000..51f34c4 --- /dev/null +++ b/crates/evm-test-support/src/anvil.rs @@ -0,0 +1,239 @@ +//! `AnvilInstance`: spawns a per-test, isolated `anvil` process on a random free port and tears +//! it down in `Drop` (including on panic, since `Drop::drop` runs during unwinding) — the local +//! EVM devnet story `StellarNetwork::Standalone` already gives Stellar work +//! (`crates/wallet-core/src/signer.rs`). + +use crate::abi::Address; +use crate::rpc::{hex_to_u64, u64_to_hex, RpcClient, RpcError}; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AnvilError { + #[error("failed to spawn `anvil` (is Foundry installed and on PATH?): {0}")] + Spawn(#[source] std::io::Error), + #[error("anvil did not print a `Listening on` line within {0:?}")] + NoListenLine(Duration), + #[error("anvil did not respond to eth_chainId within {0:?}: {1}")] + NotReady(Duration, RpcError), + #[error(transparent)] + Rpc(#[from] RpcError), +} + +const LISTEN_WAIT: Duration = Duration::from_secs(10); +const READY_WAIT: Duration = Duration::from_secs(10); + +/// A single isolated local Anvil chain. Each instance gets its own OS process and port, so tests +/// can run in parallel (`cargo test` runs test binaries' `#[test]` functions concurrently by +/// default) without sharing chain state. +pub struct AnvilInstance { + child: Child, + port: u16, + rpc_url: String, + rpc: RpcClient, + chain_id: u64, +} + +impl AnvilInstance { + /// Spawns `anvil --port 0` (letting the OS assign a free port), parses the port back out of + /// its startup banner (`Listening on 127.0.0.1:`), then confirms readiness with a + /// polling `eth_chainId` call before returning. + pub async fn spawn() -> Result { + let mut child = Command::new("anvil") + .arg("--port") + .arg("0") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(AnvilError::Spawn)?; + + let stdout = child.stdout.take().expect("stdout was piped"); + let stderr = child.stderr.take().expect("stderr was piped"); + + // Drain stderr unconditionally in the background so anvil never blocks on a full pipe. + thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines() { + if line.is_err() { + break; + } + } + }); + + let (port_tx, port_rx) = mpsc::channel(); + thread::spawn(move || { + let reader = BufReader::new(stdout); + let mut reported = false; + for line in reader.lines() { + let Ok(line) = line else { break }; + if !reported { + if let Some(port) = parse_listening_port(&line) { + reported = true; + let _ = port_tx.send(port); + } + } + // Keep draining after the match: anvil keeps writing to stdout for its lifetime, + // and an unread pipe would eventually block the child process. + } + }); + + let port = match port_rx.recv_timeout(LISTEN_WAIT) { + Ok(port) => port, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(AnvilError::NoListenLine(LISTEN_WAIT)); + } + }; + + let rpc_url = format!("http://127.0.0.1:{port}"); + let rpc = RpcClient::new(rpc_url.clone()); + + let chain_id = match wait_until_ready(&rpc, READY_WAIT).await { + Ok(chain_id) => chain_id, + Err(source) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(AnvilError::NotReady(READY_WAIT, source)); + } + }; + + Ok(Self { + child, + port, + rpc_url, + rpc, + chain_id, + }) + } + + pub fn rpc_url(&self) -> &str { + &self.rpc_url + } + + pub fn port(&self) -> u16 { + self.port + } + + pub fn chain_id(&self) -> u64 { + self.chain_id + } + + /// Unix PID of the anvil process — exposed only for tests that need to assert liveness + /// externally (e.g. `kill -0`); not meaningful once the instance has been dropped. + pub fn pid(&self) -> u32 { + self.child.id() + } + + pub(crate) fn rpc(&self) -> &RpcClient { + &self.rpc + } + + /// The deterministic, pre-funded, pre-unlocked accounts Anvil derives from its default + /// mnemonic (`test test test ... junk`) — no `--mnemonic` flag needed since that's already + /// Anvil's default when none is given. + pub async fn accounts(&self) -> Result, AnvilError> { + let value = self.rpc.call("eth_accounts", json!([])).await?; + let addresses = value + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| v.as_str().and_then(Address::from_hex)) + .collect(); + Ok(addresses) + } + + pub async fn block_number(&self) -> Result { + let value = self.rpc.call("eth_blockNumber", json!([])).await?; + Ok(hex_to_u64(value.as_str().unwrap_or("0x0"))) + } + + /// Fetches a transaction receipt if one exists, or `None` if the transaction hasn't been + /// mined yet — or, after a [`Self::revert_to`] past the block that mined it, no longer + /// exists. This is the observable that proves `snapshot`/`revert_to` actually reorg the + /// chain rather than merely resetting some in-memory counter. + pub async fn transaction_receipt(&self, tx_hash: &str) -> Result, AnvilError> { + let value = self + .rpc + .call("eth_getTransactionReceipt", json!([tx_hash])) + .await?; + Ok(if value.is_null() { None } else { Some(value) }) + } + + /// Takes an EVM state snapshot, returning an opaque id to pass to [`Self::revert_to`]. + pub async fn snapshot(&self) -> Result { + let value = self.rpc.call("anvil_snapshot", json!([])).await?; + Ok(value.as_str().unwrap_or_default().to_string()) + } + + /// Reverts chain state (balances, code, and mined blocks) back to a prior [`Self::snapshot`]. + /// This is the reorg primitive #222 depends on: it removes blocks that were already mined, + /// which is exactly what a real chain reorg does and what cannot be induced on-demand against + /// a public testnet. + pub async fn revert_to(&self, snapshot_id: &str) -> Result { + let value = self.rpc.call("anvil_revert", json!([snapshot_id])).await?; + Ok(value.as_bool().unwrap_or(false)) + } + + /// Mines `n` new blocks, one `evm_mine` call at a time. + pub async fn mine(&self, n: u32) -> Result<(), AnvilError> { + for _ in 0..n { + self.rpc.call("evm_mine", json!([])).await?; + } + Ok(()) + } + + /// Sets the base fee that will apply to the *next* mined block. + pub async fn set_base_fee(&self, wei: u64) -> Result<(), AnvilError> { + self.rpc + .call("anvil_setNextBlockBaseFeePerGas", json!([u64_to_hex(wei)])) + .await?; + Ok(()) + } +} + +impl Drop for AnvilInstance { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn parse_listening_port(line: &str) -> Option { + let rest = line.strip_prefix("Listening on ")?; + let (_, port) = rest.rsplit_once(':')?; + port.trim().parse().ok() +} + +async fn wait_until_ready(rpc: &RpcClient, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut last_err = None; + while Instant::now() < deadline { + match rpc.call("eth_chainId", json!([])).await { + Ok(value) => return Ok(hex_to_u64(value.as_str().unwrap_or("0x0"))), + Err(e) => last_err = Some(e), + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(last_err.unwrap_or_else(|| RpcError::Malformed("eth_chainId never responded".into()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_real_anvil_startup_line() { + assert_eq!( + parse_listening_port("Listening on 127.0.0.1:44061"), + Some(44061) + ); + assert_eq!(parse_listening_port("Available Accounts"), None); + } +} diff --git a/crates/evm-test-support/src/erc20.rs b/crates/evm-test-support/src/erc20.rs new file mode 100644 index 0000000..0181034 --- /dev/null +++ b/crates/evm-test-support/src/erc20.rs @@ -0,0 +1,166 @@ +//! Deploys and drives a minimal ERC-20 (`contracts/MockERC20.sol`) against an [`AnvilInstance`], +//! with `decimals` fixed at construction time so tests can exercise 6 (USDC-style), 18 +//! (DAI-style), and 0 decimal configurations against a real deployed contract and real `Transfer` +//! logs — this is where decimal-handling bugs in the ingest/amount code actually show up. +//! +//! Transactions go through plain `eth_sendTransaction` against Anvil's default accounts, which +//! are pre-unlocked (Anvil holds and signs with the private keys derived from its own default +//! mnemonic) — no client-side signing needed here, that's the production signer's job, not this +//! harness's. + +use crate::abi::{self, Address}; +use crate::anvil::{AnvilError, AnvilInstance}; +use crate::rpc::RpcError; +use serde_json::{json, Value}; +use std::time::{Duration, Instant}; + +/// Precompiled creation bytecode for `contracts/MockERC20.sol` (solc 0.8.24, optimizer on, 200 +/// runs — see the header comment in that file for how to regenerate it). Cargo never invokes +/// solc: only `anvil` needs to be on PATH to run these tests. +const MOCK_ERC20_BYTECODE: &str = include_str!("../contracts/MockERC20.bin"); + +const RECEIPT_WAIT: Duration = Duration::from_secs(10); + +pub struct MockErc20<'a> { + anvil: &'a AnvilInstance, + address: Address, + deployer: Address, + decimals: u8, +} + +impl<'a> MockErc20<'a> { + pub async fn deploy( + anvil: &'a AnvilInstance, + deployer: Address, + name: &str, + symbol: &str, + decimals: u8, + initial_supply: u128, + ) -> Result, AnvilError> { + let ctor_args = abi::encode_constructor_args(name, symbol, decimals, initial_supply); + let data = format!("{}{}", MOCK_ERC20_BYTECODE.trim(), hex::encode(ctor_args)); + + let tx_hash = send_transaction(anvil, deployer, None, &data).await?; + let receipt = wait_for_receipt(anvil, &tx_hash).await?; + let address = receipt + .get("contractAddress") + .and_then(Value::as_str) + .and_then(Address::from_hex) + .ok_or_else(|| RpcError::Malformed("deploy receipt had no contractAddress".into()))?; + + Ok(MockErc20 { + anvil, + address, + deployer, + decimals, + }) + } + + pub fn address(&self) -> Address { + self.address + } + + pub fn decimals(&self) -> u8 { + self.decimals + } + + /// Mints `amount` to `to`. Reverts on-chain (surfaced as an `AnvilError`) unless called by + /// the deploying account, matching the contract's owner-only `mint`. + pub async fn mint(&self, to: Address, amount: u128) -> Result { + let data = abi::encode_address_uint256_call("mint(address,uint256)", to, amount); + let tx_hash = send_transaction( + self.anvil, + self.deployer, + Some(self.address), + &format!("0x{}", hex::encode(data)), + ) + .await?; + wait_for_receipt(self.anvil, &tx_hash).await?; + Ok(tx_hash) + } + + /// Transfers `amount` from `from` to `to`, producing a real `Transfer` log — this is the + /// event downstream ingest/reorg tests scan for. + pub async fn transfer( + &self, + from: Address, + to: Address, + amount: u128, + ) -> Result { + let data = abi::encode_address_uint256_call("transfer(address,uint256)", to, amount); + let tx_hash = send_transaction( + self.anvil, + from, + Some(self.address), + &format!("0x{}", hex::encode(data)), + ) + .await?; + wait_for_receipt(self.anvil, &tx_hash).await?; + Ok(tx_hash) + } + + pub async fn balance_of(&self, who: Address) -> Result { + let data = abi::encode_address_call("balanceOf(address)", who); + let value = self + .anvil + .rpc() + .call( + "eth_call", + json!([ + { "to": self.address.to_hex(), "data": format!("0x{}", hex::encode(data)) }, + "latest", + ]), + ) + .await?; + let bytes = hex::decode(value.as_str().unwrap_or("0x").trim_start_matches("0x")) + .unwrap_or_default(); + Ok(abi::decode_uint256(&bytes)) + } +} + +async fn send_transaction( + anvil: &AnvilInstance, + from: Address, + to: Option
, + data: &str, +) -> Result { + let mut params = serde_json::Map::new(); + params.insert("from".to_string(), json!(from.to_hex())); + if let Some(to) = to { + params.insert("to".to_string(), json!(to.to_hex())); + } + params.insert("data".to_string(), json!(data)); + + let value = anvil + .rpc() + .call("eth_sendTransaction", json!([Value::Object(params)])) + .await?; + Ok(value.as_str().unwrap_or_default().to_string()) +} + +/// Polls for a transaction receipt until one appears (Anvil auto-mines, so this resolves in +/// practice within one or two polls), then errors out if the transaction reverted. +async fn wait_for_receipt(anvil: &AnvilInstance, tx_hash: &str) -> Result { + let deadline = Instant::now() + RECEIPT_WAIT; + loop { + if let Some(receipt) = anvil.transaction_receipt(tx_hash).await? { + let status = receipt + .get("status") + .and_then(Value::as_str) + .unwrap_or("0x0"); + if status != "0x1" { + return Err(RpcError::Malformed(format!( + "transaction {tx_hash} reverted (status {status})" + )) + .into()); + } + return Ok(receipt); + } + if Instant::now() >= deadline { + return Err( + RpcError::Malformed(format!("timed out waiting for receipt of {tx_hash}")).into(), + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} diff --git a/crates/evm-test-support/src/lib.rs b/crates/evm-test-support/src/lib.rs new file mode 100644 index 0000000..ab28fdb --- /dev/null +++ b/crates/evm-test-support/src/lib.rs @@ -0,0 +1,81 @@ +//! Anvil-based local EVM devnet harness for integration tests. +//! +//! Gives EVM work the same local-devnet story Stellar already has via +//! `StellarNetwork::Standalone` (`crates/wallet-core/src/signer.rs`): per-test isolated chain +//! instances on random free ports, deterministic funded accounts, a mock ERC-20 with +//! configurable `decimals`, and reorg controls (`snapshot`/`revert_to`) — the one thing a public +//! testnet cannot give you on demand. +//! +//! Call [`gate`] at the top of every test that needs a live Anvil and return early when it +//! returns `Some(reason)`, mirroring the `OCTO_LIVE_TESTS` pattern in +//! `crates/api/tests/horizon_live_tests.rs`. A contributor without Foundry installed still gets a +//! green `just test`; `just test-evm` sets `OCTO_EVM_TESTS=1` to opt in. + +pub mod abi; +pub mod anvil; +pub mod erc20; +pub mod rpc; + +pub use abi::Address; +pub use anvil::{AnvilError, AnvilInstance}; +pub use erc20::MockErc20; + +/// `None` if EVM integration tests should run; `Some(reason)` if they should skip cleanly. +pub fn gate() -> Option<&'static str> { + let evm_tests_enabled = std::env::var("OCTO_EVM_TESTS").as_deref() == Ok("1"); + gate_impl(evm_tests_enabled, anvil_on_path()) +} + +fn gate_impl(evm_tests_enabled: bool, anvil_present: bool) -> Option<&'static str> { + if !evm_tests_enabled { + return Some( + "SKIPPED: set OCTO_EVM_TESTS=1 to run EVM integration tests (requires Foundry's \ + `anvil` on PATH) — see `just test-evm`", + ); + } + if !anvil_present { + return Some( + "SKIPPED: OCTO_EVM_TESTS=1 but `anvil` was not found on PATH — install Foundry: \ + https://getfoundry.sh", + ); + } + None +} + +fn anvil_on_path() -> bool { + anvil_present_in(std::env::var_os("PATH").as_deref()) +} + +fn anvil_present_in(path_var: Option<&std::ffi::OsStr>) -> bool { + let Some(path) = path_var else { + return false; + }; + std::env::split_paths(path).any(|dir| dir.join("anvil").is_file()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gate_skips_when_env_var_unset() { + assert!(gate_impl(false, true).is_some()); + } + + #[test] + fn gate_skips_when_anvil_missing_even_with_env_var_set() { + assert!(gate_impl(true, false).is_some()); + } + + #[test] + fn gate_runs_when_both_conditions_are_met() { + assert!(gate_impl(true, true).is_none()); + } + + #[test] + fn anvil_present_in_returns_false_for_a_directory_without_anvil() { + assert!(!anvil_present_in(Some(std::ffi::OsStr::new( + "/nonexistent-dir-xyz-octo-test" + )))); + } +} diff --git a/crates/evm-test-support/src/rpc.rs b/crates/evm-test-support/src/rpc.rs new file mode 100644 index 0000000..fa856e1 --- /dev/null +++ b/crates/evm-test-support/src/rpc.rs @@ -0,0 +1,99 @@ +//! A deliberately minimal JSON-RPC 2.0 client — just enough to drive Anvil from the test harness. +//! Not the resilient, typed client the `octo-evm-rpc` epic will eventually build; that one adds +//! retry/circuit-breaking and per-chain policy on top of the same trap this client already avoids: +//! a JSON-RPC error arrives as HTTP 200 with an `error` member, so checking HTTP status alone +//! would read every failure as success. + +use serde_json::Value; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RpcError { + #[error("transport error calling {method}: {source}")] + Transport { + method: String, + #[source] + source: reqwest::Error, + }, + #[error("JSON-RPC error calling {method}: code {code}, {message}")] + JsonRpc { + method: String, + code: i64, + message: String, + }, + #[error("malformed JSON-RPC response: {0}")] + Malformed(String), +} + +#[derive(Clone)] +pub struct RpcClient { + http: reqwest::Client, + url: String, +} + +impl RpcClient { + pub fn new(url: impl Into) -> Self { + Self { + http: reqwest::Client::new(), + url: url.into(), + } + } + + pub async fn call(&self, method: &str, params: Value) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + + let response = self + .http + .post(&self.url) + .json(&body) + .send() + .await + .map_err(|source| RpcError::Transport { + method: method.to_string(), + source, + })?; + + let value: Value = response + .json() + .await + .map_err(|source| RpcError::Transport { + method: method.to_string(), + source, + })?; + + if let Some(error) = value.get("error") { + let code = error.get("code").and_then(Value::as_i64).unwrap_or(0); + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + return Err(RpcError::JsonRpc { + method: method.to_string(), + code, + message, + }); + } + + value + .get("result") + .cloned() + .ok_or_else(|| RpcError::Malformed(format!("missing `result` field calling {method}"))) + } +} + +/// Parses a `0x`-prefixed hex quantity into a `u64`. Panics on malformed input — every call site +/// here is parsing a value that just came back from Anvil itself, not untrusted external input. +pub fn hex_to_u64(hex: &str) -> u64 { + u64::from_str_radix(hex.trim_start_matches("0x"), 16) + .unwrap_or_else(|e| panic!("malformed hex quantity {hex:?}: {e}")) +} + +pub fn u64_to_hex(value: u64) -> String { + format!("0x{value:x}") +} diff --git a/crates/evm-test-support/tests/harness_self_test.rs b/crates/evm-test-support/tests/harness_self_test.rs new file mode 100644 index 0000000..8f15400 --- /dev/null +++ b/crates/evm-test-support/tests/harness_self_test.rs @@ -0,0 +1,166 @@ +//! Required self-tests for the Anvil harness itself (issue #219 / doc "Issue #7", Anvil-based EVM +//! integration test harness). If `snapshot_and_revert_undoes_a_mined_transfer` does not pass, +//! #222 (reorg handling) cannot be verified — `snapshot`/`revert_to` is the reorg primitive it +//! depends on. +//! +//! Every test below calls `require_anvil!()` first and returns (not panics/fails) when Anvil +//! isn't available, so `just test` (no `OCTO_EVM_TESTS`, no Foundry) stays green. + +use octo_evm_test_support::{gate, AnvilInstance, MockErc20}; + +macro_rules! require_anvil { + () => { + if let Some(reason) = gate() { + eprintln!("{reason}"); + return; + } + }; +} + +#[tokio::test] +async fn anvil_boots_and_mining_advances_the_chain() { + require_anvil!(); + + let anvil = AnvilInstance::spawn().await.expect("anvil should spawn"); + let before = anvil.block_number().await.expect("block number"); + + anvil.mine(3).await.expect("mine 3 blocks"); + + let after = anvil.block_number().await.expect("block number"); + assert_eq!( + after, + before + 3, + "eth_blockNumber should advance by exactly 3" + ); +} + +#[tokio::test] +async fn snapshot_and_revert_undoes_a_mined_transfer() { + require_anvil!(); + + let anvil = AnvilInstance::spawn().await.expect("anvil should spawn"); + let accounts = anvil.accounts().await.expect("accounts"); + let (alice, bob) = (accounts[0], accounts[1]); + + let token = MockErc20::deploy( + &anvil, + alice, + "Dai Stablecoin", + "DAI", + 18, + 1_000_000_000_000_000_000_000, + ) + .await + .expect("deploy MockERC20"); + + let snapshot_id = anvil.snapshot().await.expect("snapshot"); + + let transfer_tx = token + .transfer(alice, bob, 5_000_000_000_000_000_000) + .await + .expect("transfer"); + + assert!( + anvil + .transaction_receipt(&transfer_tx) + .await + .expect("receipt lookup") + .is_some(), + "transfer receipt should exist before the revert" + ); + + anvil.mine(2).await.expect("mine past the transfer"); + + let reverted = anvil.revert_to(&snapshot_id).await.expect("revert"); + assert!(reverted, "anvil_revert should report success"); + + // The block that mined the transfer no longer exists post-revert — this is the reorg + // primitive #222 depends on: it proves a reorg can be induced on demand at all, which is + // impossible against a public testnet. + assert!( + anvil + .transaction_receipt(&transfer_tx) + .await + .expect("receipt lookup") + .is_none(), + "transfer receipt should be gone after reverting past it" + ); +} + +/// The env-var half of the skip-cleanly requirement (the PATH-probe half is unit-tested +/// deterministically in `src/lib.rs`, since mutating `PATH` here would race other tests running +/// in parallel in the same process). This asserts `gate()` — the actual function every test in +/// this file calls — returns a reason rather than panicking when `OCTO_EVM_TESTS` isn't set, +/// which is exactly the code path a contributor without Foundry hits on a plain `cargo test`. +#[test] +fn suite_skips_cleanly_when_the_env_var_is_unset() { + if std::env::var("OCTO_EVM_TESTS").as_deref() != Ok("1") { + assert!(gate().is_some()); + } +} + +#[tokio::test] +async fn mock_erc20_supports_usdc_dai_and_zero_decimal_configs() { + require_anvil!(); + + let anvil = AnvilInstance::spawn().await.expect("anvil should spawn"); + let accounts = anvil.accounts().await.expect("accounts"); + let (deployer, recipient) = (accounts[0], accounts[1]); + + // 6 (USDC), 18 (DAI), and 0 decimals — the configurations that break decimal-handling code. + let configs: [(&str, u8, u128, u128); 3] = [ + ("USDC-style", 6, 1_000_000_000, 250_000_000), + ( + "DAI-style", + 18, + 1_000_000_000_000_000_000_000, + 1_000_000_000_000_000_000, + ), + ("zero-decimal", 0, 1_000, 42), + ]; + + for (label, decimals, initial_supply, transfer_amount) in configs { + let token = MockErc20::deploy(&anvil, deployer, label, "TOK", decimals, initial_supply) + .await + .unwrap_or_else(|e| panic!("deploy {label} (decimals={decimals}): {e}")); + assert_eq!(token.decimals(), decimals); + + token + .transfer(deployer, recipient, transfer_amount) + .await + .unwrap_or_else(|e| panic!("transfer for {label}: {e}")); + + let balance = token + .balance_of(recipient) + .await + .unwrap_or_else(|e| panic!("balance_of for {label}: {e}")); + assert_eq!(balance, transfer_amount, "{label} balance after transfer"); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn drop_kills_the_anvil_process_even_when_the_test_panics() { + require_anvil!(); + + let anvil = AnvilInstance::spawn().await.expect("anvil should spawn"); + let pid = anvil.pid(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _anvil = anvil; // moved in; Drop runs here while unwinding past this closure. + panic!("simulated test failure after acquiring an AnvilInstance"); + })); + assert!(result.is_err(), "the inner closure should have panicked"); + + // Give the OS a brief moment to reap the killed process before checking. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + let status = std::process::Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .status() + .expect("`kill -0` should run"); + assert!( + !status.success(), + "anvil process {pid} should be dead after Drop ran during unwind" + ); +} diff --git a/justfile b/justfile index 9a1ea05..0c1f636 100644 --- a/justfile +++ b/justfile @@ -17,6 +17,10 @@ test: test-live: OCTO_LIVE_TESTS=1 cargo test --workspace +# Run EVM integration tests against a local Anvil (Foundry). Requires `anvil` on PATH. +test-evm: + OCTO_EVM_TESTS=1 cargo test -p octo-evm-test-support + # Format the code. fmt: cargo fmt --all