Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions crates/evm-test-support/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions crates/evm-test-support/contracts/MockERC20.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0x608060405234801562000010575f80fd5b5060405162000b2338038062000b238339810160408190526200003391620001db565b5f620000408582620002ea565b5060016200004f8482620002ea565b506002805460ff841660ff19909116179055600480546001600160a01b031916331790558015620000865762000086338262000090565b50505050620003dc565b8060035f828254620000a39190620003b6565b90915550506001600160a01b0382165f9081526005602052604081208054839290620000d1908490620003b6565b90915550506040518181526001600160a01b038316905f907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b634e487b7160e01b5f52604160045260245ffd5b5f82601f8301126200013e575f80fd5b81516001600160401b03808211156200015b576200015b6200011a565b604051601f8301601f19908116603f011681019082821181831017156200018657620001866200011a565b8160405283815260209250866020858801011115620001a3575f80fd5b5f91505b83821015620001c65785820183015181830184015290820190620001a7565b5f602085830101528094505050505092915050565b5f805f8060808587031215620001ef575f80fd5b84516001600160401b038082111562000206575f80fd5b62000214888389016200012e565b955060208701519150808211156200022a575f80fd5b5062000239878288016200012e565b935050604085015160ff8116811462000250575f80fd5b6060959095015193969295505050565b600181811c908216806200027557607f821691505b6020821081036200029457634e487b7160e01b5f52602260045260245ffd5b50919050565b601f821115620002e557805f5260205f20601f840160051c81016020851015620002c15750805b601f840160051c820191505b81811015620002e2575f8155600101620002cd565b50505b505050565b81516001600160401b038111156200030657620003066200011a565b6200031e8162000317845462000260565b846200029a565b602080601f83116001811462000354575f84156200033c5750858301515b5f19600386901b1c1916600185901b178555620003ae565b5f85815260208120601f198616915b82811015620003845788860151825594840194600190910190840162000363565b5085821015620003a257878501515f19600388901b60f8161c191681555b505060018460011b0185555b505050505050565b80820180821115620003d657634e487b7160e01b5f52601160045260245ffd5b92915050565b61073980620003ea5f395ff3fe608060405234801561000f575f80fd5b50600436106100a6575f3560e01c806340c10f191161006e57806340c10f191461013457806370a08231146101495780638da5cb5b1461016857806395d89b4114610193578063a9059cbb1461019b578063dd62ed3e146101ae575f80fd5b806306fdde03146100aa578063095ea7b3146100c857806318160ddd146100eb57806323b872dd14610102578063313ce56714610115575b5f80fd5b6100b26101d8565b6040516100bf91906105ae565b60405180910390f35b6100db6100d6366004610615565b610263565b60405190151581526020016100bf565b6100f460035481565b6040519081526020016100bf565b6100db61011036600461063d565b6102cf565b6002546101229060ff1681565b60405160ff90911681526020016100bf565b610147610142366004610615565b610394565b005b6100f4610157366004610676565b60056020525f908152604090205481565b60045461017b906001600160a01b031681565b6040516001600160a01b0390911681526020016100bf565b6100b26103f3565b6100db6101a9366004610615565b610400565b6100f46101bc366004610696565b600660209081525f928352604080842090915290825290205481565b5f80546101e4906106c7565b80601f0160208091040260200160405190810160405280929190818152602001828054610210906106c7565b801561025b5780601f106102325761010080835404028352916020019161025b565b820191905f5260205f20905b81548152906001019060200180831161023e57829003601f168201915b505050505081565b335f8181526006602090815260408083206001600160a01b038716808552925280832085905551919290917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925906102bd9086815260200190565b60405180910390a35060015b92915050565b6001600160a01b0383165f908152600660209081526040808320338452909152812054828110156103515760405162461bcd60e51b815260206004820152602160248201527f4d6f636b45524332303a20696e73756666696369656e7420616c6c6f77616e636044820152606560f81b60648201526084015b60405180910390fd5b61035b8382610713565b6001600160a01b0386165f908152600660209081526040808320338452909152902055610389858585610415565b506001949350505050565b6004546001600160a01b031633146103e55760405162461bcd60e51b815260206004820152601460248201527326b7b1b5a2a92199181d103737ba1037bbb732b960611b6044820152606401610348565b6103ef8282610528565b5050565b600180546101e4906106c7565b5f61040c338484610415565b50600192915050565b6001600160a01b0383165f9081526005602052604090205481111561047c5760405162461bcd60e51b815260206004820152601f60248201527f4d6f636b45524332303a20696e73756666696369656e742062616c616e6365006044820152606401610348565b6001600160a01b0383165f90815260056020526040812080548392906104a3908490610713565b90915550506001600160a01b0382165f90815260056020526040812080548392906104cf908490610726565b92505081905550816001600160a01b0316836001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8360405161051b91815260200190565b60405180910390a3505050565b8060035f8282546105399190610726565b90915550506001600160a01b0382165f9081526005602052604081208054839290610565908490610726565b90915550506040518181526001600160a01b038316905f907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b5f602080835283518060208501525f5b818110156105da578581018301518582016040015282016105be565b505f604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b0381168114610610575f80fd5b919050565b5f8060408385031215610626575f80fd5b61062f836105fa565b946020939093013593505050565b5f805f6060848603121561064f575f80fd5b610658846105fa565b9250610666602085016105fa565b9150604084013590509250925092565b5f60208284031215610686575f80fd5b61068f826105fa565b9392505050565b5f80604083850312156106a7575f80fd5b6106b0836105fa565b91506106be602084016105fa565b90509250929050565b600181811c908216806106db57607f821691505b6020821081036106f957634e487b7160e01b5f52602260045260245ffd5b50919050565b634e487b7160e01b5f52601160045260245ffd5b818103818111156102c9576102c96106ff565b808201808211156102c9576102c96106ff56
78 changes: 78 additions & 0 deletions crates/evm-test-support/contracts/MockERC20.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
135 changes: 135 additions & 0 deletions crates/evm-test-support/src/abi.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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);
}
}
Loading