Skip to main content

circles_sdk/
runner.rs

1//! Runner abstractions and concrete wallet-backed implementations for write-capable SDK flows.
2//!
3//! The SDK prepares transactions as ABI-encoded calls and delegates submission to
4//! an implementation of [`ContractRunner`]. This keeps the read path independent
5//! from any wallet, Safe, or signer transport while still allowing a concrete
6//! execution backend when the caller wants write parity.
7
8use alloy_network::AnyNetwork;
9use alloy_primitives::{Address, B256, Bytes, U256, aliases::TxHash};
10use alloy_provider::{Identity, Provider, ProviderBuilder, RootProvider};
11use alloy_rpc_types::TransactionRequest;
12use alloy_signer_local::PrivateKeySigner;
13use alloy_sol_types::SolCall;
14use async_trait::async_trait;
15use reqwest::Url;
16use safe_rs::{
17    Call, CallBuilder, ChainConfig, Eoa, EoaBatchResult, Error as SafeRsError, ExecutionResult,
18    IMultiSend, ISafe, Operation, Safe, SafeTxParams, Wallet, WalletBuilder,
19    encoding::{compute_safe_transaction_hash, encode_multisend_data},
20};
21use thiserror::Error;
22
23type AnyHttpProvider = RootProvider<AnyNetwork>;
24type SafeWallet = Wallet<Safe<AnyHttpProvider>>;
25type EoaWallet = Wallet<Eoa<AnyHttpProvider>>;
26
27/// Prepared transaction for a runner to submit. This is intentionally simple;
28/// we can swap to richer contract-specific types as we wire more flows.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PreparedTransaction {
31    /// Contract address to call.
32    pub to: Address,
33    /// ABI-encoded calldata.
34    pub data: Bytes,
35    /// Optional native value to send alongside the call.
36    pub value: Option<U256>,
37}
38
39/// Helper to turn a SolCall into a prepared transaction.
40pub fn call_to_tx<C: SolCall>(to: Address, call: C, value: Option<U256>) -> PreparedTransaction {
41    PreparedTransaction {
42        to,
43        data: Bytes::from(call.abi_encode()),
44        value,
45    }
46}
47
48/// Result stub for submitted transactions.
49///
50/// For Safe-backed execution this will usually contain a single item because the
51/// full batch is submitted atomically as one on-chain Safe transaction.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct SubmittedTx {
54    /// Runner-reported transaction hash bytes.
55    pub tx_hash: Bytes,
56    /// Whether the underlying backend reported this transaction as successful.
57    pub success: bool,
58    /// Position of the transaction inside a sequential batch, when meaningful.
59    pub index: Option<usize>,
60}
61
62/// Prepared Safe execution data for browser/external-signature workflows.
63///
64/// This is the Rust analogue of the TypeScript Safe batch `getSafeTransaction()`
65/// seam: it captures the canonical Safe transaction fields plus the EIP-712 hash
66/// that an external signer/backend would sign before turning it into an
67/// `execTransaction` call.
68#[derive(Debug, Clone)]
69pub struct PreparedSafeExecution {
70    /// Safe account that will execute the transaction.
71    pub safe_address: Address,
72    /// Chain id used when computing the Safe EIP-712 hash.
73    pub chain_id: u64,
74    /// Original SDK-prepared transactions before Safe wrapping.
75    pub transactions: Vec<PreparedTransaction>,
76    /// Canonical Safe transaction parameters.
77    pub safe_tx: SafeTxParams,
78    /// EIP-712 hash that the signer/backend must authorize.
79    pub safe_tx_hash: B256,
80}
81
82impl PreparedSafeExecution {
83    /// Whether this prepared Safe execution wraps multiple inner transactions.
84    pub fn is_batch(&self) -> bool {
85        self.transactions.len() > 1
86    }
87
88    /// Build the final Safe `execTransaction` call once signatures are available.
89    pub fn to_exec_transaction(&self, signatures: Bytes) -> PreparedTransaction {
90        call_to_tx(
91            self.safe_address,
92            ISafe::execTransactionCall {
93                to: self.safe_tx.to,
94                value: self.safe_tx.value,
95                data: self.safe_tx.data.clone(),
96                operation: self.safe_tx.operation.as_u8(),
97                safeTxGas: self.safe_tx.safe_tx_gas,
98                baseGas: self.safe_tx.base_gas,
99                gasPrice: self.safe_tx.gas_price,
100                gasToken: self.safe_tx.gas_token,
101                refundReceiver: self.safe_tx.refund_receiver,
102                signatures,
103            },
104            None,
105        )
106    }
107}
108
109/// Read-only Safe execution builder for browser/external signing workflows.
110///
111/// Unlike [`SafeContractRunner`], this does not require a local private key and
112/// does not execute transactions. It only fetches the current Safe nonce/chain id
113/// and produces the canonical Safe payload/hash a browser signer would need next.
114pub struct SafeExecutionBuilder {
115    safe_address: Address,
116    provider: AnyHttpProvider,
117}
118
119impl SafeExecutionBuilder {
120    /// Connect a Safe execution builder to the given RPC URL and Safe address.
121    pub fn connect(rpc_url: &str, safe_address: Address) -> Result<Self, RunnerError> {
122        let rpc_url = parse_rpc_url(rpc_url)?;
123        let provider = build_read_provider(rpc_url);
124        Ok(Self {
125            safe_address,
126            provider,
127        })
128    }
129
130    /// Safe address this builder targets.
131    pub fn safe_address(&self) -> Address {
132        self.safe_address
133    }
134
135    /// Prepare a canonical Safe transaction from one or more SDK transactions.
136    pub async fn prepare_transactions(
137        &self,
138        txs: Vec<PreparedTransaction>,
139    ) -> Result<PreparedSafeExecution, RunnerError> {
140        let chain_id = self
141            .provider
142            .get_chain_id()
143            .await
144            .map_err(|err| RunnerError::Transport(err.to_string()))?;
145        let nonce = ISafe::new(self.safe_address, &self.provider)
146            .nonce()
147            .call()
148            .await
149            .map_err(|err| RunnerError::Transport(err.to_string()))?;
150        prepare_safe_execution(self.safe_address, chain_id, nonce, txs)
151    }
152}
153
154/// Buffered batch helper mirroring the TypeScript `BatchRun` concept.
155#[async_trait]
156pub trait BatchRun: Send {
157    /// Add a transaction to the buffered batch.
158    fn add_transaction(&mut self, tx: PreparedTransaction);
159
160    /// Execute the buffered transactions via the underlying runner.
161    async fn run(&mut self) -> Result<Vec<SubmittedTx>, RunnerError>;
162}
163
164/// Default batch-run implementation for any [`ContractRunner`].
165pub struct BufferedBatchRun<'a, R: ContractRunner + ?Sized> {
166    runner: &'a R,
167    txs: Vec<PreparedTransaction>,
168}
169
170impl<'a, R: ContractRunner + ?Sized> BufferedBatchRun<'a, R> {
171    /// Create a buffered batch bound to the given runner.
172    pub fn new(runner: &'a R) -> Self {
173        Self {
174            runner,
175            txs: Vec::new(),
176        }
177    }
178}
179
180#[async_trait]
181impl<R: ContractRunner + ?Sized> BatchRun for BufferedBatchRun<'_, R> {
182    fn add_transaction(&mut self, tx: PreparedTransaction) {
183        self.txs.push(tx);
184    }
185
186    async fn run(&mut self) -> Result<Vec<SubmittedTx>, RunnerError> {
187        self.runner
188            .send_transactions(std::mem::take(&mut self.txs))
189            .await
190    }
191}
192
193/// Trait that allows the SDK to send transactions (e.g., via a Safe or EOA backend).
194#[async_trait]
195pub trait ContractRunner: Send + Sync {
196    /// Address of the sender/safe/owner associated with this runner.
197    fn sender_address(&self) -> Address;
198
199    /// Optional alias matching the TypeScript runner surface.
200    fn address(&self) -> Option<Address> {
201        Some(self.sender_address())
202    }
203
204    /// Estimate gas for a prepared transaction.
205    async fn estimate_gas(&self, _tx: PreparedTransaction) -> Result<u64, RunnerError> {
206        Err(RunnerError::Unsupported(
207            "gas estimation is not supported by this runner".to_string(),
208        ))
209    }
210
211    /// Execute a read-only call using the runner's backend/provider.
212    async fn call(&self, _tx: PreparedTransaction) -> Result<Bytes, RunnerError> {
213        Err(RunnerError::Unsupported(
214            "contract calls are not supported by this runner".to_string(),
215        ))
216    }
217
218    /// Resolve a name to an address when supported.
219    ///
220    /// Rust currently guarantees direct hex-address parsing here; richer ENS
221    /// integration remains a backend follow-up.
222    async fn resolve_name(&self, name: &str) -> Result<Option<Address>, RunnerError> {
223        Ok(name.parse().ok())
224    }
225
226    /// Create a buffered batch helper using this runner.
227    fn send_batch_transaction(&self) -> Box<dyn BatchRun + '_> {
228        Box::new(BufferedBatchRun::new(self))
229    }
230
231    /// Submit one or more prepared transactions.
232    async fn send_transactions(
233        &self,
234        txs: Vec<PreparedTransaction>,
235    ) -> Result<Vec<SubmittedTx>, RunnerError>;
236}
237
238/// Errors surfaced by the runner.
239#[derive(Debug, Error)]
240pub enum RunnerError {
241    #[error("runner refused to send transactions: {0}")]
242    Rejected(String),
243    #[error("runner transport error: {0}")]
244    Transport(String),
245    #[error("runner capability unsupported: {0}")]
246    Unsupported(String),
247}
248
249fn tx_hash_to_bytes(tx_hash: TxHash) -> Bytes {
250    Bytes::copy_from_slice(tx_hash.as_slice())
251}
252
253fn prepared_to_request(from: Option<Address>, tx: PreparedTransaction) -> TransactionRequest {
254    let mut request = TransactionRequest::default()
255        .to(tx.to)
256        .input(tx.data.into())
257        .with_input_and_data();
258
259    if let Some(from) = from {
260        request = request.from(from);
261    }
262
263    if let Some(value) = tx.value {
264        request = request.value(value);
265    }
266
267    request
268}
269
270fn submitted_from_execution_result(result: ExecutionResult) -> Vec<SubmittedTx> {
271    vec![SubmittedTx {
272        tx_hash: tx_hash_to_bytes(result.tx_hash),
273        success: result.success,
274        index: None,
275    }]
276}
277
278fn submitted_from_eoa_result(result: EoaBatchResult) -> Vec<SubmittedTx> {
279    result
280        .results
281        .into_iter()
282        .map(|tx| SubmittedTx {
283            tx_hash: tx_hash_to_bytes(tx.tx_hash),
284            success: tx.success,
285            index: Some(tx.index),
286        })
287        .collect()
288}
289
290fn map_safe_error(error: SafeRsError) -> RunnerError {
291    match error {
292        SafeRsError::Provider(_) | SafeRsError::Fetch { .. } | SafeRsError::UnsupportedChain(_) => {
293            RunnerError::Transport(error.to_string())
294        }
295        _ => RunnerError::Rejected(error.to_string()),
296    }
297}
298
299fn parse_rpc_url(rpc_url: &str) -> Result<Url, RunnerError> {
300    rpc_url
301        .parse()
302        .map_err(|err| RunnerError::Rejected(format!("invalid rpc url: {err}")))
303}
304
305fn parse_private_key(private_key: &str) -> Result<PrivateKeySigner, RunnerError> {
306    private_key
307        .parse()
308        .map_err(|err| RunnerError::Rejected(format!("invalid private key: {err}")))
309}
310
311fn build_read_provider(rpc_url: Url) -> AnyHttpProvider {
312    ProviderBuilder::<Identity, Identity, AnyNetwork>::default().connect_http(rpc_url)
313}
314
315fn prepared_to_safe_call(tx: &PreparedTransaction) -> Call {
316    Call::new(tx.to, tx.value.unwrap_or_default(), tx.data.clone())
317}
318
319fn build_safe_inner_tx(
320    txs: &[PreparedTransaction],
321    chain_config: &ChainConfig,
322) -> (Address, U256, Bytes, Operation) {
323    if txs.len() == 1 {
324        let tx = &txs[0];
325        (
326            tx.to,
327            tx.value.unwrap_or_default(),
328            tx.data.clone(),
329            Operation::Call,
330        )
331    } else {
332        let calls = txs.iter().map(prepared_to_safe_call).collect::<Vec<_>>();
333        let transactions = encode_multisend_data(&calls);
334        let calldata = Bytes::from(IMultiSend::multiSendCall { transactions }.abi_encode());
335        (
336            chain_config.addresses.multi_send,
337            U256::ZERO,
338            calldata,
339            Operation::DelegateCall,
340        )
341    }
342}
343
344fn prepare_safe_execution(
345    safe_address: Address,
346    chain_id: u64,
347    nonce: U256,
348    txs: Vec<PreparedTransaction>,
349) -> Result<PreparedSafeExecution, RunnerError> {
350    if txs.is_empty() {
351        return Err(RunnerError::Rejected(
352            "no transactions provided".to_string(),
353        ));
354    }
355
356    let chain_config = ChainConfig::new(chain_id);
357    let (to, value, data, operation) = build_safe_inner_tx(&txs, &chain_config);
358    let safe_tx = SafeTxParams {
359        to,
360        value,
361        data,
362        operation,
363        safe_tx_gas: U256::ZERO,
364        base_gas: U256::ZERO,
365        gas_price: U256::ZERO,
366        gas_token: Address::ZERO,
367        refund_receiver: Address::ZERO,
368        nonce,
369    };
370    let safe_tx_hash = compute_safe_transaction_hash(chain_id, safe_address, &safe_tx);
371
372    Ok(PreparedSafeExecution {
373        safe_address,
374        chain_id,
375        transactions: txs,
376        safe_tx,
377        safe_tx_hash,
378    })
379}
380
381fn attach_prepared_transactions<B>(builder: B, txs: Vec<PreparedTransaction>) -> B
382where
383    B: CallBuilder,
384{
385    txs.into_iter().fold(builder, |builder, tx| {
386        builder.add_raw(tx.to, tx.value.unwrap_or_default(), tx.data)
387    })
388}
389
390/// Safe-backed contract runner using `safe-rs`.
391///
392/// This currently targets single-owner (1/1 threshold) Safes, matching the
393/// current capabilities of the underlying Safe crate used here.
394pub struct SafeContractRunner {
395    wallet: SafeWallet,
396    provider: AnyHttpProvider,
397}
398
399impl SafeContractRunner {
400    /// Connect to an existing Safe using the given RPC URL, signer private key,
401    /// and Safe address.
402    pub async fn connect(
403        rpc_url: &str,
404        private_key: &str,
405        safe_address: Address,
406    ) -> Result<Self, RunnerError> {
407        let rpc_url = parse_rpc_url(rpc_url)?;
408        let signer = parse_private_key(private_key)?;
409        let provider = build_read_provider(rpc_url);
410        let wallet = WalletBuilder::new(provider.clone(), signer)
411            .connect(safe_address)
412            .await
413            .map_err(map_safe_error)?;
414        wallet
415            .inner()
416            .verify_single_owner()
417            .await
418            .map_err(map_safe_error)?;
419        Ok(Self { wallet, provider })
420    }
421
422    /// Prepare the canonical Safe payload/hash for one or more SDK transactions
423    /// without executing them immediately.
424    pub async fn prepare_transactions(
425        &self,
426        txs: Vec<PreparedTransaction>,
427    ) -> Result<PreparedSafeExecution, RunnerError> {
428        let chain_id = self
429            .provider
430            .get_chain_id()
431            .await
432            .map_err(|err| RunnerError::Transport(err.to_string()))?;
433        let nonce = ISafe::new(self.wallet.address(), &self.provider)
434            .nonce()
435            .call()
436            .await
437            .map_err(|err| RunnerError::Transport(err.to_string()))?;
438        prepare_safe_execution(self.wallet.address(), chain_id, nonce, txs)
439    }
440}
441
442#[async_trait]
443impl ContractRunner for SafeContractRunner {
444    fn sender_address(&self) -> Address {
445        self.wallet.address()
446    }
447
448    async fn estimate_gas(&self, tx: PreparedTransaction) -> Result<u64, RunnerError> {
449        self.provider
450            .estimate_gas(prepared_to_request(self.address(), tx).into())
451            .await
452            .map_err(|err| RunnerError::Transport(err.to_string()))
453    }
454
455    async fn call(&self, tx: PreparedTransaction) -> Result<Bytes, RunnerError> {
456        self.provider
457            .call(prepared_to_request(self.address(), tx).into())
458            .await
459            .map_err(|err| RunnerError::Transport(err.to_string()))
460    }
461
462    async fn send_transactions(
463        &self,
464        txs: Vec<PreparedTransaction>,
465    ) -> Result<Vec<SubmittedTx>, RunnerError> {
466        if txs.is_empty() {
467            return Err(RunnerError::Rejected(
468                "no transactions provided".to_string(),
469            ));
470        }
471
472        let result = attach_prepared_transactions(self.wallet.batch(), txs)
473            .execute()
474            .await
475            .map_err(map_safe_error)?;
476        Ok(submitted_from_execution_result(result))
477    }
478}
479
480/// EOA-backed contract runner using the same call-builder model as the Safe runner.
481///
482/// Unlike Safe execution, multi-transaction batches are submitted sequentially
483/// and therefore are not atomic.
484pub struct EoaContractRunner {
485    wallet: EoaWallet,
486    provider: AnyHttpProvider,
487}
488
489impl EoaContractRunner {
490    /// Connect to an EOA signer using the given RPC URL and private key.
491    pub async fn connect(rpc_url: &str, private_key: &str) -> Result<Self, RunnerError> {
492        let rpc_url = parse_rpc_url(rpc_url)?;
493        let signer = parse_private_key(private_key)?;
494        let provider = build_read_provider(rpc_url.clone());
495        let wallet = WalletBuilder::new(provider.clone(), signer)
496            .connect_eoa(rpc_url)
497            .await
498            .map_err(map_safe_error)?;
499        Ok(Self { wallet, provider })
500    }
501}
502
503#[async_trait]
504impl ContractRunner for EoaContractRunner {
505    fn sender_address(&self) -> Address {
506        self.wallet.address()
507    }
508
509    async fn estimate_gas(&self, tx: PreparedTransaction) -> Result<u64, RunnerError> {
510        self.provider
511            .estimate_gas(prepared_to_request(self.address(), tx).into())
512            .await
513            .map_err(|err| RunnerError::Transport(err.to_string()))
514    }
515
516    async fn call(&self, tx: PreparedTransaction) -> Result<Bytes, RunnerError> {
517        self.provider
518            .call(prepared_to_request(self.address(), tx).into())
519            .await
520            .map_err(|err| RunnerError::Transport(err.to_string()))
521    }
522
523    async fn send_transactions(
524        &self,
525        txs: Vec<PreparedTransaction>,
526    ) -> Result<Vec<SubmittedTx>, RunnerError> {
527        if txs.is_empty() {
528            return Err(RunnerError::Rejected(
529                "no transactions provided".to_string(),
530            ));
531        }
532
533        let result = attach_prepared_transactions(self.wallet.batch(), txs)
534            .execute()
535            .await
536            .map_err(map_safe_error)?;
537        Ok(submitted_from_eoa_result(result))
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use alloy_node_bindings::Anvil;
545    use alloy_primitives::{TxKind, address};
546    use alloy_provider::Provider;
547    use safe_rs::{Call, EoaTxResult, SimulationResult};
548    use std::process::{Command, Stdio};
549    use std::sync::Mutex;
550
551    const ANVIL_FIRST_PRIVATE_KEY: &str =
552        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
553    const ANVIL_FIRST_ADDRESS: Address = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
554    const ANVIL_SECOND_ADDRESS: Address = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8");
555
556    fn anvil_binary_available() -> bool {
557        Command::new("anvil")
558            .arg("--version")
559            .stdout(Stdio::null())
560            .stderr(Stdio::null())
561            .status()
562            .is_ok()
563    }
564
565    struct RecordingRunner {
566        sender: Address,
567        sent: Mutex<Vec<Vec<PreparedTransaction>>>,
568    }
569
570    impl Default for RecordingRunner {
571        fn default() -> Self {
572            Self {
573                sender: Address::repeat_byte(0x55),
574                sent: Mutex::new(Vec::new()),
575            }
576        }
577    }
578
579    #[async_trait]
580    impl ContractRunner for RecordingRunner {
581        fn sender_address(&self) -> Address {
582            self.sender
583        }
584
585        async fn send_transactions(
586            &self,
587            txs: Vec<PreparedTransaction>,
588        ) -> Result<Vec<SubmittedTx>, RunnerError> {
589            self.sent.lock().expect("record sent txs").push(txs);
590            Ok(vec![SubmittedTx {
591                tx_hash: Bytes::copy_from_slice(&[0x77; 32]),
592                success: true,
593                index: None,
594            }])
595        }
596    }
597
598    #[derive(Default)]
599    struct StubBuilder {
600        calls: Vec<Call>,
601        simulation_result: Option<SimulationResult>,
602    }
603
604    impl CallBuilder for StubBuilder {
605        fn calls_mut(&mut self) -> &mut Vec<Call> {
606            &mut self.calls
607        }
608
609        fn calls(&self) -> &Vec<Call> {
610            &self.calls
611        }
612
613        fn with_gas_limit(self, _gas_limit: u64) -> Self {
614            self
615        }
616
617        async fn simulate(self) -> safe_rs::Result<Self> {
618            Ok(self)
619        }
620
621        fn simulation_result(&self) -> Option<&SimulationResult> {
622            self.simulation_result.as_ref()
623        }
624
625        fn simulation_success(self) -> safe_rs::Result<Self> {
626            Ok(self)
627        }
628    }
629
630    #[test]
631    fn attach_prepared_transactions_preserves_order_and_default_value() {
632        let txs = vec![
633            PreparedTransaction {
634                to: Address::repeat_byte(0x11),
635                data: Bytes::from_static(&[0xaa, 0xbb]),
636                value: None,
637            },
638            PreparedTransaction {
639                to: Address::repeat_byte(0x22),
640                data: Bytes::from_static(&[0xcc]),
641                value: Some(U256::from(42u64)),
642            },
643        ];
644
645        let builder = attach_prepared_transactions(StubBuilder::default(), txs);
646        assert_eq!(builder.calls.len(), 2);
647        assert_eq!(builder.calls[0].to, Address::repeat_byte(0x11));
648        assert_eq!(builder.calls[0].value, U256::ZERO);
649        assert_eq!(builder.calls[0].data, Bytes::from_static(&[0xaa, 0xbb]));
650        assert_eq!(builder.calls[1].to, Address::repeat_byte(0x22));
651        assert_eq!(builder.calls[1].value, U256::from(42u64));
652        assert_eq!(builder.calls[1].data, Bytes::from_static(&[0xcc]));
653    }
654
655    #[test]
656    fn submitted_from_execution_result_returns_single_hash() {
657        let submitted = submitted_from_execution_result(ExecutionResult {
658            tx_hash: TxHash::repeat_byte(0x44),
659            success: true,
660        });
661
662        assert_eq!(submitted.len(), 1);
663        assert_eq!(submitted[0].tx_hash, Bytes::copy_from_slice(&[0x44; 32]));
664        assert!(submitted[0].success);
665        assert_eq!(submitted[0].index, None);
666    }
667
668    #[test]
669    fn submitted_from_eoa_result_preserves_order() {
670        let submitted = submitted_from_eoa_result(EoaBatchResult {
671            results: vec![
672                EoaTxResult {
673                    tx_hash: TxHash::repeat_byte(0x01),
674                    success: true,
675                    index: 0,
676                },
677                EoaTxResult {
678                    tx_hash: TxHash::repeat_byte(0x02),
679                    success: true,
680                    index: 1,
681                },
682            ],
683            success_count: 2,
684            failure_count: 0,
685            first_failure: None,
686        });
687
688        assert_eq!(submitted.len(), 2);
689        assert_eq!(submitted[0].tx_hash, Bytes::copy_from_slice(&[0x01; 32]));
690        assert_eq!(submitted[1].tx_hash, Bytes::copy_from_slice(&[0x02; 32]));
691        assert!(submitted[0].success);
692        assert!(submitted[1].success);
693        assert_eq!(submitted[0].index, Some(0));
694        assert_eq!(submitted[1].index, Some(1));
695    }
696
697    #[test]
698    fn prepare_safe_execution_uses_direct_call_for_single_transaction() {
699        let tx = PreparedTransaction {
700            to: Address::repeat_byte(0x11),
701            data: Bytes::from_static(&[0xaa, 0xbb]),
702            value: Some(U256::from(7u64)),
703        };
704
705        let prepared = prepare_safe_execution(
706            Address::repeat_byte(0x44),
707            100,
708            U256::from(9u64),
709            vec![tx.clone()],
710        )
711        .expect("prepare safe execution");
712
713        assert!(!prepared.is_batch());
714        assert_eq!(prepared.transactions, vec![tx.clone()]);
715        assert_eq!(prepared.safe_tx.to, tx.to);
716        assert_eq!(prepared.safe_tx.value, U256::from(7u64));
717        assert_eq!(prepared.safe_tx.data, tx.data);
718        assert_eq!(prepared.safe_tx.operation, Operation::Call);
719        assert_eq!(prepared.safe_tx.nonce, U256::from(9u64));
720        assert_eq!(
721            prepared.safe_tx_hash,
722            compute_safe_transaction_hash(100, Address::repeat_byte(0x44), &prepared.safe_tx)
723        );
724    }
725
726    #[test]
727    fn prepare_safe_execution_uses_multisend_for_batches() {
728        let txs = vec![
729            PreparedTransaction {
730                to: Address::repeat_byte(0x11),
731                data: Bytes::from_static(&[0xaa]),
732                value: None,
733            },
734            PreparedTransaction {
735                to: Address::repeat_byte(0x22),
736                data: Bytes::from_static(&[0xbb, 0xcc]),
737                value: Some(U256::from(5u64)),
738            },
739        ];
740        let calls = txs.iter().map(prepared_to_safe_call).collect::<Vec<_>>();
741        let expected_multisend = encode_multisend_data(&calls);
742        let expected_data = Bytes::from(
743            IMultiSend::multiSendCall {
744                transactions: expected_multisend,
745            }
746            .abi_encode(),
747        );
748
749        let prepared = prepare_safe_execution(
750            Address::repeat_byte(0x55),
751            100,
752            U256::from(3u64),
753            txs.clone(),
754        )
755        .expect("prepare safe execution");
756
757        assert!(prepared.is_batch());
758        assert_eq!(prepared.transactions, txs);
759        assert_eq!(
760            prepared.safe_tx.to,
761            ChainConfig::new(100).addresses.multi_send
762        );
763        assert_eq!(prepared.safe_tx.value, U256::ZERO);
764        assert_eq!(prepared.safe_tx.operation, Operation::DelegateCall);
765        assert_eq!(prepared.safe_tx.data, expected_data);
766    }
767
768    #[test]
769    fn prepared_safe_execution_builds_exec_transaction_call() {
770        let prepared = prepare_safe_execution(
771            Address::repeat_byte(0x66),
772            100,
773            U256::from(1u64),
774            vec![PreparedTransaction {
775                to: Address::repeat_byte(0x11),
776                data: Bytes::from_static(&[0xab, 0xcd]),
777                value: Some(U256::from(2u64)),
778            }],
779        )
780        .expect("prepare safe execution");
781
782        let tx = prepared.to_exec_transaction(Bytes::from_static(&[0x12, 0x34]));
783        let decoded =
784            ISafe::execTransactionCall::abi_decode(&tx.data).expect("decode execTransaction call");
785
786        assert_eq!(tx.to, prepared.safe_address);
787        assert_eq!(tx.value, None);
788        assert_eq!(decoded.to, prepared.safe_tx.to);
789        assert_eq!(decoded.value, prepared.safe_tx.value);
790        assert_eq!(decoded.data, prepared.safe_tx.data);
791        assert_eq!(decoded.operation, prepared.safe_tx.operation.as_u8());
792        assert_eq!(decoded.safeTxGas, prepared.safe_tx.safe_tx_gas);
793        assert_eq!(decoded.baseGas, prepared.safe_tx.base_gas);
794        assert_eq!(decoded.gasPrice, prepared.safe_tx.gas_price);
795        assert_eq!(decoded.gasToken, prepared.safe_tx.gas_token);
796        assert_eq!(decoded.refundReceiver, prepared.safe_tx.refund_receiver);
797        assert_eq!(decoded.signatures, Bytes::from_static(&[0x12, 0x34]));
798    }
799
800    #[test]
801    fn prepare_safe_execution_rejects_empty_batches() {
802        let result = prepare_safe_execution(Address::ZERO, 100, U256::ZERO, Vec::new());
803
804        assert!(matches!(result, Err(RunnerError::Rejected(_))));
805    }
806
807    #[test]
808    fn prepared_to_request_preserves_fields_and_duplicates_input_data() {
809        let tx = PreparedTransaction {
810            to: ANVIL_SECOND_ADDRESS,
811            data: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
812            value: Some(U256::from(321u64)),
813        };
814
815        let request = prepared_to_request(Some(ANVIL_FIRST_ADDRESS), tx.clone());
816
817        assert_eq!(request.from, Some(ANVIL_FIRST_ADDRESS));
818        assert_eq!(request.to, Some(TxKind::Call(ANVIL_SECOND_ADDRESS)));
819        assert_eq!(request.value, Some(U256::from(321u64)));
820        assert_eq!(request.input.input(), Some(&tx.data));
821        assert_eq!(request.input.data.as_ref(), Some(&tx.data));
822    }
823
824    #[test]
825    fn contract_runner_address_defaults_to_sender_address() {
826        let runner = RecordingRunner::default();
827
828        assert_eq!(runner.address(), Some(runner.sender_address()));
829    }
830
831    #[tokio::test]
832    async fn batch_runner_buffers_and_forwards_transactions() {
833        let runner = RecordingRunner::default();
834        let mut batch = runner.send_batch_transaction();
835
836        batch.add_transaction(PreparedTransaction {
837            to: Address::repeat_byte(0x11),
838            data: Bytes::from_static(&[0xaa]),
839            value: None,
840        });
841        batch.add_transaction(PreparedTransaction {
842            to: Address::repeat_byte(0x22),
843            data: Bytes::from_static(&[0xbb, 0xcc]),
844            value: Some(U256::from(9u64)),
845        });
846
847        let submitted = batch.run().await.expect("batch run succeeds");
848        let sent = runner.sent.lock().expect("inspect recorded batch");
849
850        assert_eq!(sent.len(), 1);
851        assert_eq!(sent[0].len(), 2);
852        assert_eq!(sent[0][0].to, Address::repeat_byte(0x11));
853        assert_eq!(sent[0][1].to, Address::repeat_byte(0x22));
854        assert_eq!(sent[0][1].value, Some(U256::from(9u64)));
855        assert_eq!(submitted[0].tx_hash, Bytes::copy_from_slice(&[0x77; 32]));
856        assert!(submitted[0].success);
857    }
858
859    #[tokio::test]
860    async fn default_resolve_name_parses_hex_address_strings() {
861        let runner = RecordingRunner::default();
862
863        assert_eq!(
864            runner
865                .resolve_name(&ANVIL_FIRST_ADDRESS.to_string())
866                .await
867                .expect("default resolver succeeds"),
868            Some(ANVIL_FIRST_ADDRESS)
869        );
870        assert_eq!(
871            runner
872                .resolve_name("alice.eth")
873                .await
874                .expect("default resolver succeeds"),
875            None
876        );
877    }
878
879    #[tokio::test]
880    async fn safe_runner_rejects_invalid_private_key() {
881        let result = SafeContractRunner::connect(
882            "https://rpc.example.invalid",
883            "not-a-private-key",
884            Address::ZERO,
885        )
886        .await;
887
888        assert!(matches!(result, Err(RunnerError::Rejected(_))));
889    }
890
891    #[tokio::test]
892    async fn eoa_runner_rejects_invalid_private_key() {
893        let result = EoaContractRunner::connect("https://rpc.example.invalid", "bad-key").await;
894
895        assert!(matches!(result, Err(RunnerError::Rejected(_))));
896    }
897
898    #[test]
899    fn safe_execution_builder_rejects_invalid_rpc_url() {
900        let result = SafeExecutionBuilder::connect("not-a-url", Address::ZERO);
901
902        assert!(matches!(result, Err(RunnerError::Rejected(_))));
903    }
904
905    #[tokio::test]
906    async fn eoa_runner_executes_value_transfer_on_anvil() {
907        if !anvil_binary_available() {
908            eprintln!("skipping anvil-backed test because `anvil` is not installed");
909            return;
910        }
911
912        let anvil = Anvil::new().spawn();
913        let provider = build_read_provider(anvil.endpoint_url());
914        let before = provider
915            .get_balance(ANVIL_SECOND_ADDRESS)
916            .await
917            .expect("balance before transfer");
918        let runner = EoaContractRunner::connect(&anvil.endpoint(), ANVIL_FIRST_PRIVATE_KEY)
919            .await
920            .expect("connect EOA runner");
921
922        assert_eq!(runner.sender_address(), ANVIL_FIRST_ADDRESS);
923
924        let submitted = runner
925            .send_transactions(vec![PreparedTransaction {
926                to: ANVIL_SECOND_ADDRESS,
927                data: Bytes::new(),
928                value: Some(U256::from(123u64)),
929            }])
930            .await
931            .expect("EOA transfer executes");
932
933        assert_eq!(submitted.len(), 1);
934
935        let after = provider
936            .get_balance(ANVIL_SECOND_ADDRESS)
937            .await
938            .expect("balance after transfer");
939        assert_eq!(after - before, U256::from(123u64));
940    }
941
942    #[tokio::test]
943    async fn eoa_runner_exposes_estimate_and_call_helpers() {
944        if !anvil_binary_available() {
945            eprintln!("skipping anvil-backed test because `anvil` is not installed");
946            return;
947        }
948
949        let anvil = Anvil::new().spawn();
950        let runner = EoaContractRunner::connect(&anvil.endpoint(), ANVIL_FIRST_PRIVATE_KEY)
951            .await
952            .expect("connect EOA runner");
953
954        let gas = runner
955            .estimate_gas(PreparedTransaction {
956                to: ANVIL_SECOND_ADDRESS,
957                data: Bytes::new(),
958                value: Some(U256::from(1u64)),
959            })
960            .await
961            .expect("estimate gas");
962        assert!(gas > 0);
963
964        let call_output = runner
965            .call(PreparedTransaction {
966                to: ANVIL_SECOND_ADDRESS,
967                data: Bytes::new(),
968                value: None,
969            })
970            .await
971            .expect("eth_call succeeds");
972        assert_eq!(call_output, Bytes::new());
973    }
974
975    #[tokio::test]
976    async fn safe_runner_rejects_non_safe_address_on_plain_anvil() {
977        if !anvil_binary_available() {
978            eprintln!("skipping anvil-backed test because `anvil` is not installed");
979            return;
980        }
981
982        let anvil = Anvil::new().spawn();
983        let result = SafeContractRunner::connect(
984            &anvil.endpoint(),
985            ANVIL_FIRST_PRIVATE_KEY,
986            ANVIL_FIRST_ADDRESS,
987        )
988        .await;
989
990        assert!(matches!(result, Err(RunnerError::Transport(_))));
991    }
992}