mod common;
use alloy_primitives::{Address, Bytes, U256, hex, keccak256};
use alloy_sol_types::{SolCall, SolValue, sol};
use anyhow::Result;
use revm::state::{AccountInfo, Bytecode};
use common::{MOCK_ERC20_BALANCE_SLOT, install_default_account, install_mock_erc20, setup_cache};
use evm_fork_cache::cache::EvmCache;
use evm_fork_cache::multicall::{
IMulticall3, MAX_BATCH_SIZE, MULTICALL3_ADDRESS, MulticallBatch, decode_result,
execute_batched, try_decode_result,
};
const MULTICALL3_RUNTIME_HEX: &str = include_str!("../fixtures/multicall3_runtime.hex");
sol! {
function getValue() external returns (uint256);
function balanceOf(address account) external returns (uint256);
}
fn balance_slot(owner: Address) -> U256 {
U256::from_be_bytes(keccak256((owner, U256::from(MOCK_ERC20_BALANCE_SLOT)).abi_encode()).0)
}
fn install_runtime(cache: &mut EvmCache, addr: Address, runtime_hex: &str) {
let code = Bytecode::new_raw(Bytes::from(
hex::decode(runtime_hex.trim()).expect("valid runtime hex"),
));
let code_hash = code.hash_slow();
cache.db_mut().insert_account_info(
addr,
AccountInfo {
balance: U256::ZERO,
nonce: 1,
code: Some(code),
code_hash,
account_id: None,
},
);
cache
.db_mut()
.replace_account_storage(addr, Default::default())
.expect("clear etched storage");
}
#[tokio::test(flavor = "multi_thread")]
async fn empty_batch_short_circuits() -> Result<()> {
let mut cache = setup_cache().await?;
let batch = MulticallBatch::new();
assert!(batch.is_empty());
assert!(batch.execute(&mut cache)?.is_empty());
let (results, access) = batch.execute_tracked(&mut cache)?;
assert!(results.is_empty());
assert!(access.slots.is_empty() && access.accounts.is_empty());
let batched = execute_batched(&mut cache, std::iter::empty::<(Address, Bytes, bool)>())?;
assert!(batched.is_empty());
Ok(())
}
#[test]
fn batch_len_tracks_added_calls() {
let target = Address::repeat_byte(0x11);
let mut batch = MulticallBatch::with_capacity(2);
assert_eq!(batch.len(), 0);
batch.add(target, getValueCall {}.abi_encode().into(), true);
batch.add_call(target, getValueCall {}, false);
assert_eq!(batch.len(), 2);
assert!(!batch.is_empty());
}
#[test]
fn decode_result_honors_success_flag() {
let ok = IMulticall3::Result {
success: true,
returnData: U256::from(42u64).abi_encode().into(),
};
let decoded = decode_result::<getValueCall>(&ok).expect("successful result decodes");
assert_eq!(decoded, U256::from(42u64));
assert_eq!(
try_decode_result::<getValueCall>(&ok),
Some(U256::from(42u64))
);
let failed = IMulticall3::Result {
success: false,
returnData: Bytes::new(),
};
assert!(
decode_result::<getValueCall>(&failed).is_err(),
"a failed call cannot be decoded"
);
assert_eq!(try_decode_result::<getValueCall>(&failed), None);
}
#[test]
fn decode_result_rejects_garbage_payload() {
let garbage = IMulticall3::Result {
success: true,
returnData: Bytes::from_static(&[0x01, 0x02, 0x03]),
};
assert!(decode_result::<getValueCall>(&garbage).is_err());
assert_eq!(try_decode_result::<getValueCall>(&garbage), None);
}
#[test]
fn max_batch_size_constant() {
assert_eq!(MAX_BATCH_SIZE, 200);
}
#[tokio::test(flavor = "multi_thread")]
async fn aggregate3_executes_offline_in_input_order() -> Result<()> {
let mut cache = setup_cache().await?;
install_runtime(&mut cache, MULTICALL3_ADDRESS, MULTICALL3_RUNTIME_HEX);
let token = Address::repeat_byte(0x22);
let owner = Address::repeat_byte(0x33);
let other = Address::repeat_byte(0x44);
install_default_account(&mut cache, Address::ZERO);
install_mock_erc20(&mut cache, token);
cache
.db_mut()
.insert_account_storage(token, balance_slot(owner), U256::from(5_000u64))?;
let mut batch = MulticallBatch::new();
batch.add_call(token, balanceOfCall { account: owner }, false);
batch.add_call(token, balanceOfCall { account: other }, false);
let results = batch.execute(&mut cache)?;
assert_eq!(results.len(), 2, "one result per input call");
assert!(results[0].success && results[1].success);
assert_eq!(
decode_result::<balanceOfCall>(&results[0])?,
U256::from(5_000u64)
);
assert_eq!(decode_result::<balanceOfCall>(&results[1])?, U256::ZERO);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn aggregate3_allow_failure_partial_results_and_strict_revert() -> Result<()> {
let mut cache = setup_cache().await?;
install_runtime(&mut cache, MULTICALL3_ADDRESS, MULTICALL3_RUNTIME_HEX);
let token = Address::repeat_byte(0x22);
let owner = Address::repeat_byte(0x33);
install_default_account(&mut cache, Address::ZERO);
install_mock_erc20(&mut cache, token);
cache
.db_mut()
.insert_account_storage(token, balance_slot(owner), U256::from(7u64))?;
let reverting = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]);
let mut batch = MulticallBatch::new();
batch.add_call(token, balanceOfCall { account: owner }, false);
batch.add(token, reverting.clone(), true); let (results, access) = batch.execute_tracked(&mut cache)?;
assert_eq!(results.len(), 2);
assert!(results[0].success);
assert_eq!(
decode_result::<balanceOfCall>(&results[0])?,
U256::from(7u64)
);
assert!(
!results[1].success,
"the reverting call surfaces as success = false, not an Err"
);
assert!(
access.accounts.contains(&token),
"execute_tracked must capture the token account the inner call touched"
);
let mut strict = MulticallBatch::new();
strict.add(token, reverting, false);
assert!(
strict.execute(&mut cache).is_err(),
"a non-allowed revert reverts the whole batch"
);
Ok(())
}