use alloy_network::AnyNetwork;
use alloy_primitives::{Address, B256, Bytes, U256, aliases::TxHash};
use alloy_provider::{Identity, Provider, ProviderBuilder, RootProvider};
use alloy_rpc_types::TransactionRequest;
use alloy_signer_local::PrivateKeySigner;
use alloy_sol_types::SolCall;
use async_trait::async_trait;
use reqwest::Url;
use safe_rs::{
Call, CallBuilder, ChainConfig, Eoa, EoaBatchResult, Error as SafeRsError, ExecutionResult,
IMultiSend, ISafe, Operation, Safe, SafeTxParams, Wallet, WalletBuilder,
encoding::{compute_safe_transaction_hash, encode_multisend_data},
};
use thiserror::Error;
type AnyHttpProvider = RootProvider<AnyNetwork>;
type SafeWallet = Wallet<Safe<AnyHttpProvider>>;
type EoaWallet = Wallet<Eoa<AnyHttpProvider>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedTransaction {
pub to: Address,
pub data: Bytes,
pub value: Option<U256>,
}
pub fn call_to_tx<C: SolCall>(to: Address, call: C, value: Option<U256>) -> PreparedTransaction {
PreparedTransaction {
to,
data: Bytes::from(call.abi_encode()),
value,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubmittedTx {
pub tx_hash: Bytes,
pub success: bool,
pub index: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct PreparedSafeExecution {
pub safe_address: Address,
pub chain_id: u64,
pub transactions: Vec<PreparedTransaction>,
pub safe_tx: SafeTxParams,
pub safe_tx_hash: B256,
}
impl PreparedSafeExecution {
pub fn is_batch(&self) -> bool {
self.transactions.len() > 1
}
pub fn to_exec_transaction(&self, signatures: Bytes) -> PreparedTransaction {
call_to_tx(
self.safe_address,
ISafe::execTransactionCall {
to: self.safe_tx.to,
value: self.safe_tx.value,
data: self.safe_tx.data.clone(),
operation: self.safe_tx.operation.as_u8(),
safeTxGas: self.safe_tx.safe_tx_gas,
baseGas: self.safe_tx.base_gas,
gasPrice: self.safe_tx.gas_price,
gasToken: self.safe_tx.gas_token,
refundReceiver: self.safe_tx.refund_receiver,
signatures,
},
None,
)
}
}
pub struct SafeExecutionBuilder {
safe_address: Address,
provider: AnyHttpProvider,
}
impl SafeExecutionBuilder {
pub fn connect(rpc_url: &str, safe_address: Address) -> Result<Self, RunnerError> {
let rpc_url = parse_rpc_url(rpc_url)?;
let provider = build_read_provider(rpc_url);
Ok(Self {
safe_address,
provider,
})
}
pub fn safe_address(&self) -> Address {
self.safe_address
}
pub async fn prepare_transactions(
&self,
txs: Vec<PreparedTransaction>,
) -> Result<PreparedSafeExecution, RunnerError> {
let chain_id = self
.provider
.get_chain_id()
.await
.map_err(|err| RunnerError::Transport(err.to_string()))?;
let nonce = ISafe::new(self.safe_address, &self.provider)
.nonce()
.call()
.await
.map_err(|err| RunnerError::Transport(err.to_string()))?;
prepare_safe_execution(self.safe_address, chain_id, nonce, txs)
}
}
#[async_trait]
pub trait BatchRun: Send {
fn add_transaction(&mut self, tx: PreparedTransaction);
async fn run(&mut self) -> Result<Vec<SubmittedTx>, RunnerError>;
}
pub struct BufferedBatchRun<'a, R: ContractRunner + ?Sized> {
runner: &'a R,
txs: Vec<PreparedTransaction>,
}
impl<'a, R: ContractRunner + ?Sized> BufferedBatchRun<'a, R> {
pub fn new(runner: &'a R) -> Self {
Self {
runner,
txs: Vec::new(),
}
}
}
#[async_trait]
impl<R: ContractRunner + ?Sized> BatchRun for BufferedBatchRun<'_, R> {
fn add_transaction(&mut self, tx: PreparedTransaction) {
self.txs.push(tx);
}
async fn run(&mut self) -> Result<Vec<SubmittedTx>, RunnerError> {
self.runner
.send_transactions(std::mem::take(&mut self.txs))
.await
}
}
#[async_trait]
pub trait ContractRunner: Send + Sync {
fn sender_address(&self) -> Address;
fn address(&self) -> Option<Address> {
Some(self.sender_address())
}
async fn estimate_gas(&self, _tx: PreparedTransaction) -> Result<u64, RunnerError> {
Err(RunnerError::Unsupported(
"gas estimation is not supported by this runner".to_string(),
))
}
async fn call(&self, _tx: PreparedTransaction) -> Result<Bytes, RunnerError> {
Err(RunnerError::Unsupported(
"contract calls are not supported by this runner".to_string(),
))
}
async fn resolve_name(&self, name: &str) -> Result<Option<Address>, RunnerError> {
Ok(name.parse().ok())
}
fn send_batch_transaction(&self) -> Box<dyn BatchRun + '_> {
Box::new(BufferedBatchRun::new(self))
}
async fn send_transactions(
&self,
txs: Vec<PreparedTransaction>,
) -> Result<Vec<SubmittedTx>, RunnerError>;
}
#[derive(Debug, Error)]
pub enum RunnerError {
#[error("runner refused to send transactions: {0}")]
Rejected(String),
#[error("runner transport error: {0}")]
Transport(String),
#[error("runner capability unsupported: {0}")]
Unsupported(String),
}
fn tx_hash_to_bytes(tx_hash: TxHash) -> Bytes {
Bytes::copy_from_slice(tx_hash.as_slice())
}
fn prepared_to_request(from: Option<Address>, tx: PreparedTransaction) -> TransactionRequest {
let mut request = TransactionRequest::default()
.to(tx.to)
.input(tx.data.into())
.with_input_and_data();
if let Some(from) = from {
request = request.from(from);
}
if let Some(value) = tx.value {
request = request.value(value);
}
request
}
fn submitted_from_execution_result(result: ExecutionResult) -> Vec<SubmittedTx> {
vec![SubmittedTx {
tx_hash: tx_hash_to_bytes(result.tx_hash),
success: result.success,
index: None,
}]
}
fn submitted_from_eoa_result(result: EoaBatchResult) -> Vec<SubmittedTx> {
result
.results
.into_iter()
.map(|tx| SubmittedTx {
tx_hash: tx_hash_to_bytes(tx.tx_hash),
success: tx.success,
index: Some(tx.index),
})
.collect()
}
fn map_safe_error(error: SafeRsError) -> RunnerError {
match error {
SafeRsError::Provider(_) | SafeRsError::Fetch { .. } | SafeRsError::UnsupportedChain(_) => {
RunnerError::Transport(error.to_string())
}
_ => RunnerError::Rejected(error.to_string()),
}
}
fn parse_rpc_url(rpc_url: &str) -> Result<Url, RunnerError> {
rpc_url
.parse()
.map_err(|err| RunnerError::Rejected(format!("invalid rpc url: {err}")))
}
fn parse_private_key(private_key: &str) -> Result<PrivateKeySigner, RunnerError> {
private_key
.parse()
.map_err(|err| RunnerError::Rejected(format!("invalid private key: {err}")))
}
fn build_read_provider(rpc_url: Url) -> AnyHttpProvider {
ProviderBuilder::<Identity, Identity, AnyNetwork>::default().connect_http(rpc_url)
}
fn prepared_to_safe_call(tx: &PreparedTransaction) -> Call {
Call::new(tx.to, tx.value.unwrap_or_default(), tx.data.clone())
}
fn build_safe_inner_tx(
txs: &[PreparedTransaction],
chain_config: &ChainConfig,
) -> (Address, U256, Bytes, Operation) {
if txs.len() == 1 {
let tx = &txs[0];
(
tx.to,
tx.value.unwrap_or_default(),
tx.data.clone(),
Operation::Call,
)
} else {
let calls = txs.iter().map(prepared_to_safe_call).collect::<Vec<_>>();
let transactions = encode_multisend_data(&calls);
let calldata = Bytes::from(IMultiSend::multiSendCall { transactions }.abi_encode());
(
chain_config.addresses.multi_send,
U256::ZERO,
calldata,
Operation::DelegateCall,
)
}
}
fn prepare_safe_execution(
safe_address: Address,
chain_id: u64,
nonce: U256,
txs: Vec<PreparedTransaction>,
) -> Result<PreparedSafeExecution, RunnerError> {
if txs.is_empty() {
return Err(RunnerError::Rejected(
"no transactions provided".to_string(),
));
}
let chain_config = ChainConfig::new(chain_id);
let (to, value, data, operation) = build_safe_inner_tx(&txs, &chain_config);
let safe_tx = SafeTxParams {
to,
value,
data,
operation,
safe_tx_gas: U256::ZERO,
base_gas: U256::ZERO,
gas_price: U256::ZERO,
gas_token: Address::ZERO,
refund_receiver: Address::ZERO,
nonce,
};
let safe_tx_hash = compute_safe_transaction_hash(chain_id, safe_address, &safe_tx);
Ok(PreparedSafeExecution {
safe_address,
chain_id,
transactions: txs,
safe_tx,
safe_tx_hash,
})
}
fn attach_prepared_transactions<B>(builder: B, txs: Vec<PreparedTransaction>) -> B
where
B: CallBuilder,
{
txs.into_iter().fold(builder, |builder, tx| {
builder.add_raw(tx.to, tx.value.unwrap_or_default(), tx.data)
})
}
pub struct SafeContractRunner {
wallet: SafeWallet,
provider: AnyHttpProvider,
}
impl SafeContractRunner {
pub async fn connect(
rpc_url: &str,
private_key: &str,
safe_address: Address,
) -> Result<Self, RunnerError> {
let rpc_url = parse_rpc_url(rpc_url)?;
let signer = parse_private_key(private_key)?;
let provider = build_read_provider(rpc_url);
let wallet = WalletBuilder::new(provider.clone(), signer)
.connect(safe_address)
.await
.map_err(map_safe_error)?;
wallet
.inner()
.verify_single_owner()
.await
.map_err(map_safe_error)?;
Ok(Self { wallet, provider })
}
pub async fn prepare_transactions(
&self,
txs: Vec<PreparedTransaction>,
) -> Result<PreparedSafeExecution, RunnerError> {
let chain_id = self
.provider
.get_chain_id()
.await
.map_err(|err| RunnerError::Transport(err.to_string()))?;
let nonce = ISafe::new(self.wallet.address(), &self.provider)
.nonce()
.call()
.await
.map_err(|err| RunnerError::Transport(err.to_string()))?;
prepare_safe_execution(self.wallet.address(), chain_id, nonce, txs)
}
}
#[async_trait]
impl ContractRunner for SafeContractRunner {
fn sender_address(&self) -> Address {
self.wallet.address()
}
async fn estimate_gas(&self, tx: PreparedTransaction) -> Result<u64, RunnerError> {
self.provider
.estimate_gas(prepared_to_request(self.address(), tx).into())
.await
.map_err(|err| RunnerError::Transport(err.to_string()))
}
async fn call(&self, tx: PreparedTransaction) -> Result<Bytes, RunnerError> {
self.provider
.call(prepared_to_request(self.address(), tx).into())
.await
.map_err(|err| RunnerError::Transport(err.to_string()))
}
async fn send_transactions(
&self,
txs: Vec<PreparedTransaction>,
) -> Result<Vec<SubmittedTx>, RunnerError> {
if txs.is_empty() {
return Err(RunnerError::Rejected(
"no transactions provided".to_string(),
));
}
let result = attach_prepared_transactions(self.wallet.batch(), txs)
.execute()
.await
.map_err(map_safe_error)?;
Ok(submitted_from_execution_result(result))
}
}
pub struct EoaContractRunner {
wallet: EoaWallet,
provider: AnyHttpProvider,
}
impl EoaContractRunner {
pub async fn connect(rpc_url: &str, private_key: &str) -> Result<Self, RunnerError> {
let rpc_url = parse_rpc_url(rpc_url)?;
let signer = parse_private_key(private_key)?;
let provider = build_read_provider(rpc_url.clone());
let wallet = WalletBuilder::new(provider.clone(), signer)
.connect_eoa(rpc_url)
.await
.map_err(map_safe_error)?;
Ok(Self { wallet, provider })
}
}
#[async_trait]
impl ContractRunner for EoaContractRunner {
fn sender_address(&self) -> Address {
self.wallet.address()
}
async fn estimate_gas(&self, tx: PreparedTransaction) -> Result<u64, RunnerError> {
self.provider
.estimate_gas(prepared_to_request(self.address(), tx).into())
.await
.map_err(|err| RunnerError::Transport(err.to_string()))
}
async fn call(&self, tx: PreparedTransaction) -> Result<Bytes, RunnerError> {
self.provider
.call(prepared_to_request(self.address(), tx).into())
.await
.map_err(|err| RunnerError::Transport(err.to_string()))
}
async fn send_transactions(
&self,
txs: Vec<PreparedTransaction>,
) -> Result<Vec<SubmittedTx>, RunnerError> {
if txs.is_empty() {
return Err(RunnerError::Rejected(
"no transactions provided".to_string(),
));
}
let result = attach_prepared_transactions(self.wallet.batch(), txs)
.execute()
.await
.map_err(map_safe_error)?;
Ok(submitted_from_eoa_result(result))
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_node_bindings::Anvil;
use alloy_primitives::{TxKind, address};
use alloy_provider::Provider;
use safe_rs::{Call, EoaTxResult, SimulationResult};
use std::process::{Command, Stdio};
use std::sync::Mutex;
const ANVIL_FIRST_PRIVATE_KEY: &str =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const ANVIL_FIRST_ADDRESS: Address = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
const ANVIL_SECOND_ADDRESS: Address = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8");
fn anvil_binary_available() -> bool {
Command::new("anvil")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
}
struct RecordingRunner {
sender: Address,
sent: Mutex<Vec<Vec<PreparedTransaction>>>,
}
impl Default for RecordingRunner {
fn default() -> Self {
Self {
sender: Address::repeat_byte(0x55),
sent: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl ContractRunner for RecordingRunner {
fn sender_address(&self) -> Address {
self.sender
}
async fn send_transactions(
&self,
txs: Vec<PreparedTransaction>,
) -> Result<Vec<SubmittedTx>, RunnerError> {
self.sent.lock().expect("record sent txs").push(txs);
Ok(vec![SubmittedTx {
tx_hash: Bytes::copy_from_slice(&[0x77; 32]),
success: true,
index: None,
}])
}
}
#[derive(Default)]
struct StubBuilder {
calls: Vec<Call>,
simulation_result: Option<SimulationResult>,
}
impl CallBuilder for StubBuilder {
fn calls_mut(&mut self) -> &mut Vec<Call> {
&mut self.calls
}
fn calls(&self) -> &Vec<Call> {
&self.calls
}
fn with_gas_limit(self, _gas_limit: u64) -> Self {
self
}
async fn simulate(self) -> safe_rs::Result<Self> {
Ok(self)
}
fn simulation_result(&self) -> Option<&SimulationResult> {
self.simulation_result.as_ref()
}
fn simulation_success(self) -> safe_rs::Result<Self> {
Ok(self)
}
}
#[test]
fn attach_prepared_transactions_preserves_order_and_default_value() {
let txs = vec![
PreparedTransaction {
to: Address::repeat_byte(0x11),
data: Bytes::from_static(&[0xaa, 0xbb]),
value: None,
},
PreparedTransaction {
to: Address::repeat_byte(0x22),
data: Bytes::from_static(&[0xcc]),
value: Some(U256::from(42u64)),
},
];
let builder = attach_prepared_transactions(StubBuilder::default(), txs);
assert_eq!(builder.calls.len(), 2);
assert_eq!(builder.calls[0].to, Address::repeat_byte(0x11));
assert_eq!(builder.calls[0].value, U256::ZERO);
assert_eq!(builder.calls[0].data, Bytes::from_static(&[0xaa, 0xbb]));
assert_eq!(builder.calls[1].to, Address::repeat_byte(0x22));
assert_eq!(builder.calls[1].value, U256::from(42u64));
assert_eq!(builder.calls[1].data, Bytes::from_static(&[0xcc]));
}
#[test]
fn submitted_from_execution_result_returns_single_hash() {
let submitted = submitted_from_execution_result(ExecutionResult {
tx_hash: TxHash::repeat_byte(0x44),
success: true,
});
assert_eq!(submitted.len(), 1);
assert_eq!(submitted[0].tx_hash, Bytes::copy_from_slice(&[0x44; 32]));
assert!(submitted[0].success);
assert_eq!(submitted[0].index, None);
}
#[test]
fn submitted_from_eoa_result_preserves_order() {
let submitted = submitted_from_eoa_result(EoaBatchResult {
results: vec![
EoaTxResult {
tx_hash: TxHash::repeat_byte(0x01),
success: true,
index: 0,
},
EoaTxResult {
tx_hash: TxHash::repeat_byte(0x02),
success: true,
index: 1,
},
],
success_count: 2,
failure_count: 0,
first_failure: None,
});
assert_eq!(submitted.len(), 2);
assert_eq!(submitted[0].tx_hash, Bytes::copy_from_slice(&[0x01; 32]));
assert_eq!(submitted[1].tx_hash, Bytes::copy_from_slice(&[0x02; 32]));
assert!(submitted[0].success);
assert!(submitted[1].success);
assert_eq!(submitted[0].index, Some(0));
assert_eq!(submitted[1].index, Some(1));
}
#[test]
fn prepare_safe_execution_uses_direct_call_for_single_transaction() {
let tx = PreparedTransaction {
to: Address::repeat_byte(0x11),
data: Bytes::from_static(&[0xaa, 0xbb]),
value: Some(U256::from(7u64)),
};
let prepared = prepare_safe_execution(
Address::repeat_byte(0x44),
100,
U256::from(9u64),
vec![tx.clone()],
)
.expect("prepare safe execution");
assert!(!prepared.is_batch());
assert_eq!(prepared.transactions, vec![tx.clone()]);
assert_eq!(prepared.safe_tx.to, tx.to);
assert_eq!(prepared.safe_tx.value, U256::from(7u64));
assert_eq!(prepared.safe_tx.data, tx.data);
assert_eq!(prepared.safe_tx.operation, Operation::Call);
assert_eq!(prepared.safe_tx.nonce, U256::from(9u64));
assert_eq!(
prepared.safe_tx_hash,
compute_safe_transaction_hash(100, Address::repeat_byte(0x44), &prepared.safe_tx)
);
}
#[test]
fn prepare_safe_execution_uses_multisend_for_batches() {
let txs = vec![
PreparedTransaction {
to: Address::repeat_byte(0x11),
data: Bytes::from_static(&[0xaa]),
value: None,
},
PreparedTransaction {
to: Address::repeat_byte(0x22),
data: Bytes::from_static(&[0xbb, 0xcc]),
value: Some(U256::from(5u64)),
},
];
let calls = txs.iter().map(prepared_to_safe_call).collect::<Vec<_>>();
let expected_multisend = encode_multisend_data(&calls);
let expected_data = Bytes::from(
IMultiSend::multiSendCall {
transactions: expected_multisend,
}
.abi_encode(),
);
let prepared = prepare_safe_execution(
Address::repeat_byte(0x55),
100,
U256::from(3u64),
txs.clone(),
)
.expect("prepare safe execution");
assert!(prepared.is_batch());
assert_eq!(prepared.transactions, txs);
assert_eq!(
prepared.safe_tx.to,
ChainConfig::new(100).addresses.multi_send
);
assert_eq!(prepared.safe_tx.value, U256::ZERO);
assert_eq!(prepared.safe_tx.operation, Operation::DelegateCall);
assert_eq!(prepared.safe_tx.data, expected_data);
}
#[test]
fn prepared_safe_execution_builds_exec_transaction_call() {
let prepared = prepare_safe_execution(
Address::repeat_byte(0x66),
100,
U256::from(1u64),
vec![PreparedTransaction {
to: Address::repeat_byte(0x11),
data: Bytes::from_static(&[0xab, 0xcd]),
value: Some(U256::from(2u64)),
}],
)
.expect("prepare safe execution");
let tx = prepared.to_exec_transaction(Bytes::from_static(&[0x12, 0x34]));
let decoded =
ISafe::execTransactionCall::abi_decode(&tx.data).expect("decode execTransaction call");
assert_eq!(tx.to, prepared.safe_address);
assert_eq!(tx.value, None);
assert_eq!(decoded.to, prepared.safe_tx.to);
assert_eq!(decoded.value, prepared.safe_tx.value);
assert_eq!(decoded.data, prepared.safe_tx.data);
assert_eq!(decoded.operation, prepared.safe_tx.operation.as_u8());
assert_eq!(decoded.safeTxGas, prepared.safe_tx.safe_tx_gas);
assert_eq!(decoded.baseGas, prepared.safe_tx.base_gas);
assert_eq!(decoded.gasPrice, prepared.safe_tx.gas_price);
assert_eq!(decoded.gasToken, prepared.safe_tx.gas_token);
assert_eq!(decoded.refundReceiver, prepared.safe_tx.refund_receiver);
assert_eq!(decoded.signatures, Bytes::from_static(&[0x12, 0x34]));
}
#[test]
fn prepare_safe_execution_rejects_empty_batches() {
let result = prepare_safe_execution(Address::ZERO, 100, U256::ZERO, Vec::new());
assert!(matches!(result, Err(RunnerError::Rejected(_))));
}
#[test]
fn prepared_to_request_preserves_fields_and_duplicates_input_data() {
let tx = PreparedTransaction {
to: ANVIL_SECOND_ADDRESS,
data: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
value: Some(U256::from(321u64)),
};
let request = prepared_to_request(Some(ANVIL_FIRST_ADDRESS), tx.clone());
assert_eq!(request.from, Some(ANVIL_FIRST_ADDRESS));
assert_eq!(request.to, Some(TxKind::Call(ANVIL_SECOND_ADDRESS)));
assert_eq!(request.value, Some(U256::from(321u64)));
assert_eq!(request.input.input(), Some(&tx.data));
assert_eq!(request.input.data.as_ref(), Some(&tx.data));
}
#[test]
fn contract_runner_address_defaults_to_sender_address() {
let runner = RecordingRunner::default();
assert_eq!(runner.address(), Some(runner.sender_address()));
}
#[tokio::test]
async fn batch_runner_buffers_and_forwards_transactions() {
let runner = RecordingRunner::default();
let mut batch = runner.send_batch_transaction();
batch.add_transaction(PreparedTransaction {
to: Address::repeat_byte(0x11),
data: Bytes::from_static(&[0xaa]),
value: None,
});
batch.add_transaction(PreparedTransaction {
to: Address::repeat_byte(0x22),
data: Bytes::from_static(&[0xbb, 0xcc]),
value: Some(U256::from(9u64)),
});
let submitted = batch.run().await.expect("batch run succeeds");
let sent = runner.sent.lock().expect("inspect recorded batch");
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].len(), 2);
assert_eq!(sent[0][0].to, Address::repeat_byte(0x11));
assert_eq!(sent[0][1].to, Address::repeat_byte(0x22));
assert_eq!(sent[0][1].value, Some(U256::from(9u64)));
assert_eq!(submitted[0].tx_hash, Bytes::copy_from_slice(&[0x77; 32]));
assert!(submitted[0].success);
}
#[tokio::test]
async fn default_resolve_name_parses_hex_address_strings() {
let runner = RecordingRunner::default();
assert_eq!(
runner
.resolve_name(&ANVIL_FIRST_ADDRESS.to_string())
.await
.expect("default resolver succeeds"),
Some(ANVIL_FIRST_ADDRESS)
);
assert_eq!(
runner
.resolve_name("alice.eth")
.await
.expect("default resolver succeeds"),
None
);
}
#[tokio::test]
async fn safe_runner_rejects_invalid_private_key() {
let result = SafeContractRunner::connect(
"https://rpc.example.invalid",
"not-a-private-key",
Address::ZERO,
)
.await;
assert!(matches!(result, Err(RunnerError::Rejected(_))));
}
#[tokio::test]
async fn eoa_runner_rejects_invalid_private_key() {
let result = EoaContractRunner::connect("https://rpc.example.invalid", "bad-key").await;
assert!(matches!(result, Err(RunnerError::Rejected(_))));
}
#[test]
fn safe_execution_builder_rejects_invalid_rpc_url() {
let result = SafeExecutionBuilder::connect("not-a-url", Address::ZERO);
assert!(matches!(result, Err(RunnerError::Rejected(_))));
}
#[tokio::test]
async fn eoa_runner_executes_value_transfer_on_anvil() {
if !anvil_binary_available() {
eprintln!("skipping anvil-backed test because `anvil` is not installed");
return;
}
let anvil = Anvil::new().spawn();
let provider = build_read_provider(anvil.endpoint_url());
let before = provider
.get_balance(ANVIL_SECOND_ADDRESS)
.await
.expect("balance before transfer");
let runner = EoaContractRunner::connect(&anvil.endpoint(), ANVIL_FIRST_PRIVATE_KEY)
.await
.expect("connect EOA runner");
assert_eq!(runner.sender_address(), ANVIL_FIRST_ADDRESS);
let submitted = runner
.send_transactions(vec![PreparedTransaction {
to: ANVIL_SECOND_ADDRESS,
data: Bytes::new(),
value: Some(U256::from(123u64)),
}])
.await
.expect("EOA transfer executes");
assert_eq!(submitted.len(), 1);
let after = provider
.get_balance(ANVIL_SECOND_ADDRESS)
.await
.expect("balance after transfer");
assert_eq!(after - before, U256::from(123u64));
}
#[tokio::test]
async fn eoa_runner_exposes_estimate_and_call_helpers() {
if !anvil_binary_available() {
eprintln!("skipping anvil-backed test because `anvil` is not installed");
return;
}
let anvil = Anvil::new().spawn();
let runner = EoaContractRunner::connect(&anvil.endpoint(), ANVIL_FIRST_PRIVATE_KEY)
.await
.expect("connect EOA runner");
let gas = runner
.estimate_gas(PreparedTransaction {
to: ANVIL_SECOND_ADDRESS,
data: Bytes::new(),
value: Some(U256::from(1u64)),
})
.await
.expect("estimate gas");
assert!(gas > 0);
let call_output = runner
.call(PreparedTransaction {
to: ANVIL_SECOND_ADDRESS,
data: Bytes::new(),
value: None,
})
.await
.expect("eth_call succeeds");
assert_eq!(call_output, Bytes::new());
}
#[tokio::test]
async fn safe_runner_rejects_non_safe_address_on_plain_anvil() {
if !anvil_binary_available() {
eprintln!("skipping anvil-backed test because `anvil` is not installed");
return;
}
let anvil = Anvil::new().spawn();
let result = SafeContractRunner::connect(
&anvil.endpoint(),
ANVIL_FIRST_PRIVATE_KEY,
ANVIL_FIRST_ADDRESS,
)
.await;
assert!(matches!(result, Err(RunnerError::Transport(_))));
}
}