use std::{
cell::RefCell,
collections::VecDeque,
future::{Future, ready},
pin::pin,
task::{Context, Poll, Waker},
};
use crate::client::{Network, Response};
use serde_json::{Value, json};
use super::*;
use crate::client::evm::fixtures;
use crate::client::evm::records::BlockTransactions;
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = pin!(future);
let mut context = Context::from_waker(Waker::noop());
loop {
if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
return value;
}
}
}
#[derive(Default)]
struct Wire {
answers: RefCell<VecDeque<Result<Response, TransportError>>>,
sent: RefCell<Vec<Request>>,
}
impl Wire {
fn answering(answers: impl IntoIterator<Item = Result<Response, TransportError>>) -> Self {
Wire { answers: RefCell::new(answers.into_iter().collect()), sent: RefCell::default() }
}
}
impl Http for Wire {
fn send(&self, request: Request) -> impl Future<Output = Result<Response, TransportError>> {
self.sent.borrow_mut().push(request);
ready(self.answers.borrow_mut().pop_front().unwrap_or(Err(TransportError::Unreachable("dry".to_owned()))))
}
}
fn ok(text: &str) -> Result<Response, TransportError> {
Ok(Response { status: 200, headers: Vec::new(), body: text.as_bytes().to_vec() })
}
fn address(text: &str) -> Address {
fixtures::address(text)
}
fn same_request<T>(query: &Query<T>, method: &str, nth: usize) {
let file = fixtures::mainnet();
let call = file["calls"].as_array().unwrap().iter().filter(|call| call["method"] == method).nth(nth).unwrap();
let sent: Value = serde_json::from_str(&query.body()).unwrap();
assert_eq!(sent, json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": call["params"]}), "{method}");
}
#[test]
fn every_request_is_the_one_the_node_accepted() {
let usdc = address("0xb88339cb7199b77e23db6e890353e22632ba630f");
let me = address("0x14791697260e4c9a71f18484c9f997b308e59325");
let send: B256 = "0x1f7c5f72fb847a4ac78c2ca49ed13b031cee23a93d9cd40025623ce973edc768".parse().unwrap();
same_request(ð::block_number(), "eth_blockNumber", 0);
same_request(ð::chain_id(), "eth_chainId", 0);
same_request(ð::transaction(send), "eth_getTransactionByHash", 0);
same_request(ð::receipt(send), "eth_getTransactionReceipt", 0);
same_request(ð::block(BlockTag::Number(0x2cc1453), false), "eth_getBlockByNumber", 0);
let raw_actions = LogFilter {
from_block: Some(BlockTag::Number(0x2cc1440)),
to_block: Some(BlockTag::Number(0x2cc1460)),
address: vec![address("0x3333333333333333333333333333333333333333")],
topics: vec![Some(vec!["0x8c7f585fb295f7eb1e6aeb8fba61b23a4fe60beda405f0045073b185c74412e3".parse().unwrap()])],
..LogFilter::default()
};
same_request(ð::logs(&raw_actions), "eth_getLogs", 0);
same_request(ð::code(address("0x3333333333333333333333333333333333333333"), BlockTag::Latest), "eth_getCode", 0);
same_request(ð::storage_at(usdc, U256::ZERO, BlockTag::Latest), "eth_getStorageAt", 0);
same_request(
ð::balance(address("0x2222222222222222222222222222222222222222"), BlockTag::Latest),
"eth_getBalance",
0,
);
same_request(
ð::transaction_count(address("0x6b9e773128f453f5c2c60935ee2de2cbc5390a24"), BlockTag::Latest),
"eth_getTransactionCount",
0,
);
same_request(ð::gas_price(), "eth_gasPrice", 0);
same_request(ð::max_priority_fee(), "eth_maxPriorityFeePerGas", 0);
same_request(ð::big_block_gas_price(), "eth_bigBlockGasPrice", 0);
same_request(ð::using_big_blocks(me), "eth_usingBigBlocks", 0);
same_request(ð::fee_history(2, BlockTag::Latest, &[25, 75]), "eth_feeHistory", 0);
let balance_of =
crate::utils::hex::decode("0x70a0823100000000000000000000000014791697260e4c9a71f18484c9f997b308e59325")
.unwrap();
same_request(
ð::estimate_gas(&CallRequest { from: Some(me), ..CallRequest::to(usdc, balance_of) }),
"eth_estimateGas",
0,
);
let init = crate::utils::hex::decode("0x600180600b6000396000f300").unwrap();
same_request(
ð::estimate_gas(&CallRequest { from: Some(me), data: init, ..CallRequest::default() }),
"eth_estimateGas",
1,
);
same_request(ð::call(&CallRequest::to(usdc, vec![0x31, 0x3c, 0xe5, 0x67]), BlockTag::Latest), "eth_call", 0);
}
#[test]
fn every_answer_reads_as_the_exact_integer_or_bytes_it_carries() {
let read = |method: &str, nth: usize| fixtures::answer(method, nth);
assert_eq!(eth::chain_id().answer(&read("eth_chainId", 0)), Ok(999));
assert_eq!(eth::block_number().answer(&read("eth_blockNumber", 1)), Ok(0x2dc97bf));
assert_eq!(
eth::balance(Address::ZERO, BlockTag::Latest).answer(&read("eth_getBalance", 0)).unwrap().to_string(),
"947499191167351653726310821"
);
assert_eq!(
eth::transaction_count(Address::ZERO, BlockTag::Latest).answer(&read("eth_getTransactionCount", 0)),
Ok(0x15ffdf)
);
assert_eq!(eth::gas_price().answer(&read("eth_gasPrice", 0)), Ok(U256::from(315_902_611u64)));
assert_eq!(eth::max_priority_fee().answer(&read("eth_maxPriorityFeePerGas", 0)), Ok(U256::ZERO));
assert_eq!(eth::big_block_gas_price().answer(&read("eth_bigBlockGasPrice", 0)), Ok(U256::from(100_000_000u64)));
assert_eq!(eth::using_big_blocks(Address::ZERO).answer(&read("eth_usingBigBlocks", 0)), Ok(false));
assert_eq!(eth::estimate_gas(&CallRequest::default()).answer(&read("eth_estimateGas", 0)), Ok(0x7b2f));
assert_eq!(eth::estimate_gas(&CallRequest::default()).answer(&read("eth_estimateGas", 1)), Ok(0xd3a1));
let decimals = eth::call(&CallRequest::default(), BlockTag::Latest).answer(&read("eth_call", 0)).unwrap();
assert_eq!(decimals.len(), 32);
assert_eq!(decimals[31], 6);
let code = eth::code(Address::ZERO, BlockTag::Latest).answer(&read("eth_getCode", 0)).unwrap();
assert!(code.windows(4).any(|window| window == [0x17, 0x93, 0x8e, 0x13]), "CoreWriter's code holds sendRawAction");
let slot =
eth::storage_at(Address::ZERO, U256::ZERO, BlockTag::Latest).answer(&read("eth_getStorageAt", 0)).unwrap();
assert_eq!(slot.to_address().unwrap().to_hex(), "0xb5653bf26797d3271dabf31c616fa2b7b216a053");
assert_eq!(eth::receipt(B256::ZERO).answer(&read("eth_getTransactionReceipt", 1)), Ok(None), "not mined");
}
#[test]
fn fee_history_keeps_its_ratios_as_written() {
let history =
eth::fee_history(2, BlockTag::Latest, &[25, 75]).answer(&fixtures::answer("eth_feeHistory", 0)).unwrap();
let said: Value = serde_json::from_str(&fixtures::answer("eth_feeHistory", 0)).unwrap();
let said = &said["result"];
assert_eq!(
history.oldest_block,
u64::from_str_radix(fixtures::text(&said["oldestBlock"]).trim_start_matches("0x"), 16).unwrap()
);
assert_eq!(history.base_fee_per_gas.len(), 3, "one more than the blocks asked: the next block's");
assert_eq!(history.base_fee_per_gas[0].to_quantity(), fixtures::text(&said["baseFeePerGas"][0]));
assert_eq!(history.next_base_fee(), Some(fixtures::text(&said["baseFeePerGas"][2]).parse().unwrap()));
let raw = fixtures::answer("eth_feeHistory", 0);
for ratio in &history.gas_used_ratio {
assert!(
raw.contains(&format!("{},", ratio.as_str())) || raw.contains(&format!("{}]", ratio.as_str())),
"{ratio}"
);
}
assert_eq!(history.reward.len(), 2);
assert_eq!(history.reward[0].len(), 2);
}
#[test]
fn a_refusal_is_a_typed_node_error() {
let not_a_method = eth::chain_id().answer(&fixtures::answer("eth_notAMethod", 0)).unwrap_err();
let RpcError::Node { method, error } = ¬_a_method else { panic!("{not_a_method:?}") };
assert_eq!(*method, "eth_chainId");
assert_eq!(
(error.code, error.message.as_str(), error.kind()),
(-32601, "Method not found", ErrorKind::MethodNotFound)
);
let reverted =
eth::call(&CallRequest::default(), BlockTag::Latest).answer(&fixtures::answer("eth_call", 2)).unwrap_err();
let RpcError::Node { error, .. } = reverted else { panic!() };
assert_eq!((error.code, error.kind(), error.revert()), (3, ErrorKind::Reverted, None));
let timeout = eth::logs(&LogFilter::default()).answer(&fixtures::answer("eth_getLogs", 0)).unwrap_err();
let RpcError::Node { error, .. } = timeout else { panic!() };
assert_eq!((error.code, error.kind()), (-32000, ErrorKind::Server));
assert_eq!(error.message, "execution aborted (timeout = 5s)");
let file = fixtures::mainnet();
let borrow =
file["precompiles"].as_array().unwrap().iter().find(|call| call["name"] == "borrowLendUserState").unwrap();
let refused =
eth::call(&CallRequest::default(), BlockTag::Latest).answer(fixtures::text(&borrow["answer"])).unwrap_err();
let RpcError::Node { error, .. } = refused else { panic!() };
assert_eq!((error.code, error.message.as_str()), (-32003, "EVM error: PrecompileError"));
assert!(matches!(eth::chain_id().answer("<html>"), Err(RpcError::Unreadable { method: "eth_chainId", .. })));
assert!(matches!(eth::chain_id().answer(r#"{"jsonrpc":"2.0","id":1}"#), Err(RpcError::Unreadable { .. })));
assert!(matches!(
eth::chain_id().answer(r#"{"jsonrpc":"2.0","id":1,"result":3}"#),
Err(RpcError::Unreadable { .. })
));
}
#[test]
fn a_revert_reads_its_reason() {
let viem = fixtures::viem();
for case in viem["reverts"].as_array().unwrap() {
let data = fixtures::text(&case["data"]);
let answer = format!(
r#"{{"jsonrpc":"2.0","id":1,"error":{{"code":3,"message":"execution reverted","data":"{data}"}}}}"#
);
let RpcError::Node { error, .. } =
eth::call(&CallRequest::default(), BlockTag::Latest).answer(&answer).unwrap_err()
else {
panic!()
};
let expected = match fixtures::text(&case["kind"]) {
"message" => Revert::Message(fixtures::text(&case["text"]).to_owned()),
_ => Revert::Panic(fixtures::text(&case["code"]).parse().unwrap()),
};
assert_eq!(error.revert(), Some(expected), "{data}");
assert_eq!(error.data.as_deref(), Some(data));
}
assert_eq!(read_revert(&[0xde, 0xad, 0xbe, 0xef, 0x01]), Revert::Custom(vec![0xde, 0xad, 0xbe, 0xef, 0x01]));
assert_eq!(read_revert(&[]), Revert::Custom(Vec::new()));
}
#[test]
fn a_log_filter_writes_one_address_bare_and_a_wildcard_as_null() {
let topic: B256 = "0x8c7f585fb295f7eb1e6aeb8fba61b23a4fe60beda405f0045073b185c74412e3".parse().unwrap();
let filter = LogFilter {
address: vec![address("0x3333333333333333333333333333333333333333"), Address::ZERO],
topics: vec![None, Some(vec![topic, B256::ZERO])],
block_hash: Some(B256::ZERO),
..LogFilter::default()
};
let sent: Value = serde_json::from_str(ð::logs(&filter).body()).unwrap();
assert_eq!(
sent["params"][0],
json!({
"blockHash": B256::ZERO.to_hex(),
"address": ["0x3333333333333333333333333333333333333333", Address::ZERO.to_hex()],
"topics": [null, [topic.to_hex(), B256::ZERO.to_hex()]],
})
);
let tags: Vec<String> = [
BlockTag::Latest,
BlockTag::Pending,
BlockTag::Earliest,
BlockTag::Safe,
BlockTag::Finalized,
BlockTag::Number(0),
]
.iter()
.map(|tag| tag.to_string())
.collect();
assert_eq!(tags, ["latest", "pending", "earliest", "safe", "finalized", "0x0"]);
let sent: Value = serde_json::from_str(ð::send_raw_transaction(&[0x02, 0xc0]).body()).unwrap();
assert_eq!(sent["params"], json!(["0x02c0"]));
let call =
CallRequest { gas: Some(21_000), value: Some(U256::from(5u8)), ..CallRequest::to(Address::ZERO, Vec::new()) };
let sent: Value = serde_json::from_str(ð::call(&call, BlockTag::Pending).body()).unwrap();
assert_eq!(sent["params"], json!([{"to": Address::ZERO.to_hex(), "gas": "0x5208", "value": "0x5"}, "pending"]));
}
#[test]
fn the_client_posts_json_to_its_url_and_reads_the_answer() {
let wire = Wire::answering([ok(&fixtures::answer("eth_chainId", 0))]);
let rpc = Rpc::hyperevm(wire, Network::Mainnet);
assert_eq!(rpc.url(), "https://rpc.hyperliquid.xyz/evm");
assert_eq!(block_on(rpc.chain_id()), Ok(999));
let sent = rpc.http().sent.borrow();
assert_eq!(sent[0].url, "https://rpc.hyperliquid.xyz/evm");
assert_eq!(sent[0].method, crate::client::Method::Post);
assert_eq!(sent[0].header("content-type"), Some("application/json"));
assert_eq!(String::from_utf8(sent[0].body.clone()).unwrap(), eth::chain_id().body());
drop(sent);
assert_eq!(Rpc::hyperevm(Wire::default(), Network::Testnet).url(), "https://rpc.hyperliquid-testnet.xyz/evm");
let arbitrum = Rpc::new(
Wire::answering([ok(r#"{"jsonrpc":"2.0","id":1,"result":"0xa4b1"}"#)]),
"https://arb1.arbitrum.io/rpc",
);
assert_eq!(block_on(arbitrum.chain_id()), Ok(crate::client::evm::chain::ARBITRUM_ONE));
}
#[test]
fn http_failures_are_kept_apart_from_node_refusals() {
let limited =
Response { status: 429, headers: vec![("Retry-After".to_owned(), "3".to_owned())], body: b"{}".to_vec() };
let rpc = Rpc::new(Wire::answering([Ok(limited)]), "https://rpc.example/evm");
assert_eq!(
block_on(rpc.block_number()),
Err(RpcError::Transport(TransportError::RateLimited { retry_after_ms: Some(3_000) }))
);
let broken = Response { status: 502, headers: Vec::new(), body: b"bad gateway".to_vec() };
let rpc = Rpc::new(Wire::answering([Ok(broken)]), "https://rpc.example/evm");
assert_eq!(
block_on(rpc.block_number()),
Err(RpcError::Transport(TransportError::Status { status: 502, body: "bad gateway".to_owned() }))
);
let refused = Response {
status: 400,
headers: Vec::new(),
body: br#"{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"invalid params"}}"#.to_vec(),
};
let rpc = Rpc::new(Wire::answering([Ok(refused)]), "https://rpc.example/evm");
let Err(RpcError::Node { error, .. }) = block_on(rpc.block_number()) else { panic!() };
assert_eq!(error.kind(), ErrorKind::InvalidParams);
let rpc = Rpc::new(Wire::default(), "https://rpc.example/evm");
assert!(matches!(
block_on(rpc.send_raw_transaction(&[0x02])),
Err(RpcError::Transport(TransportError::Unreachable(_)))
));
}
#[test]
fn the_client_reads_receipts_blocks_and_logs() {
let rpc = Rpc::hyperevm(
Wire::answering([
ok(&fixtures::answer("eth_getTransactionReceipt", 0)),
ok(&fixtures::answer("eth_getBlockByNumber", 0)),
ok(&fixtures::answer("eth_getTransactionByHash", 0)),
ok(r#"{"jsonrpc":"2.0","id":1,"result":[]}"#),
ok(
r#"{"jsonrpc":"2.0","id":1,"result":"0x1f7c5f72fb847a4ac78c2ca49ed13b031cee23a93d9cd40025623ce973edc768"}"#,
),
]),
Network::Mainnet,
);
let hash: B256 = "0x1f7c5f72fb847a4ac78c2ca49ed13b031cee23a93d9cd40025623ce973edc768".parse().unwrap();
let receipt = block_on(rpc.receipt(hash)).unwrap().unwrap();
assert!(receipt.succeeded());
assert_eq!(receipt.transaction_hash, hash);
let block = block_on(rpc.block(BlockTag::Number(0x2cc1453), false)).unwrap().unwrap();
assert_eq!(block.number, 0x2cc1453);
assert!(matches!(&block.transactions, BlockTransactions::Hashes(hashes) if hashes.contains(&hash)));
assert_eq!(block_on(rpc.transaction(hash)).unwrap().unwrap().hash, hash);
assert_eq!(block_on(rpc.logs(&LogFilter::default())), Ok(Vec::new()));
assert_eq!(block_on(rpc.send_raw_transaction(&[0x02])), Ok(hash));
}