trident-evm-aa 0.3.0

Rust glue for the Trident minimal EIP-7702 smart account: deploy, install, and batch transactions.
Documentation
//! EIP-7702 self-installer: the authority signs the authorization and pays
//! for the outer transaction.

use alloy_eips::eip7702::Authorization;
use alloy_network::{Network, TransactionBuilder, TransactionBuilder7702};
use alloy_primitives::{Address, B256, U256};
use alloy_provider::{PendingTransactionBuilder, Provider, WalletProvider};
use alloy_signer::Signer;

use crate::deployer::{self, DEFAULT_ADDRESS, DeployError};

const INSTALL_GAS_LIMIT_FALLBACK: u64 = 100_000;

/// Errors surfaced by [`SelfInstaller::install`].
#[derive(Debug, thiserror::Error)]
pub enum InstallError {
    /// Authority signer's address does not match the provider's default
    /// signer. Self-install requires they be the same key.
    #[error(
        "self-install requires authority == provider's default signer (authority {authority}, provider signer {provider_signer})"
    )]
    AuthorityMismatch {
        authority: Address,
        provider_signer: Address,
    },

    /// No `TridentAccount` bytecode at the target address and auto-deploy is
    /// disabled (or the target is a custom address, which is never
    /// auto-deployed at).
    #[error(
        "`TridentAccount` is not deployed at {target}; deploy it first or call the installer without `without_deploy_if_missing`"
    )]
    TridentAccountNotDeployed { target: Address },

    /// Auto-deploy of `TridentAccount` failed.
    #[error(transparent)]
    Deploy(#[from] DeployError),

    /// Authorization signing failed.
    #[error(transparent)]
    Signer(#[from] alloy_signer::Error),

    /// Transport-level RPC failure (chain-id lookup, nonce fetch, tx send).
    #[error("rpc: {0}")]
    Rpc(Box<dyn std::error::Error + Send + Sync>),
}

/// Builder + sender for a self-installed EIP-7702 delegation.
#[derive(Debug, Clone, Copy)]
pub struct SelfInstaller {
    target: Option<Address>,
    deploy_if_missing: bool,
}

impl Default for SelfInstaller {
    fn default() -> Self {
        Self::new()
    }
}

impl SelfInstaller {
    /// New installer with no explicit target — [`DEFAULT_ADDRESS`] is used,
    /// and auto-deploy is enabled.
    pub const fn new() -> Self {
        Self {
            target: None,
            deploy_if_missing: true,
        }
    }

    /// Override the delegation target. Custom targets are not auto-deployed.
    pub const fn with_target(mut self, target: Address) -> Self {
        self.target = Some(target);
        self
    }

    /// Opt out of auto-deploy. With this set, a missing target — even the
    /// default — causes [`install`](Self::install) to fail with
    /// [`InstallError::TridentAccountNotDeployed`].
    pub const fn without_deploy_if_missing(mut self) -> Self {
        self.deploy_if_missing = false;
        self
    }

    /// Assemble the EIP-7702 install transaction without sending it.
    ///
    /// Runs every pre-flight the installer owns — authority-vs-provider-signer
    /// match, on-chain code check, optional auto-deploy, chain-id + nonce
    /// fetch, authorization signing — and returns the type-4 transaction
    /// request ready for `provider.send_transaction(...)`. No gas limit is
    /// set; callers may inspect, customize (fees, sponsor relay, explicit
    /// gas), or just pass the result straight back to the provider.
    ///
    /// On chains whose RPC under-estimates the EIP-7702 intrinsic (notably
    /// Arbitrum), a bare send of the returned tx will fail with "intrinsic
    /// gas too low"; either set `with_gas_limit(>= 100_000)` on the tx
    /// before sending, or use [`install`](Self::install), which retries
    /// automatically.
    pub async fn prepare_install_tx<P, N, S>(
        self,
        provider: &P,
        authority: &S,
    ) -> Result<N::TransactionRequest, InstallError>
    where
        P: Provider<N> + WalletProvider<N>,
        N: Network,
        <N as Network>::TransactionRequest: TransactionBuilder7702,
        S: Signer + Send + Sync,
    {
        let authority_addr = authority.address();
        let provider_signer = provider.default_signer_address();
        if authority_addr != provider_signer {
            return Err(InstallError::AuthorityMismatch {
                authority: authority_addr,
                provider_signer,
            });
        }

        let using_default_target = self.target.is_none();
        let target = self.target.unwrap_or(DEFAULT_ADDRESS);

        let code = provider
            .get_code_at(target)
            .await
            .map_err(|e| InstallError::Rpc(Box::new(e)))?;
        if code.is_empty() {
            if self.deploy_if_missing && using_default_target {
                deployer::deploy_deterministic(provider, B256::ZERO).await?;
            } else {
                return Err(InstallError::TridentAccountNotDeployed { target });
            }
        }

        let chain_id = provider
            .get_chain_id()
            .await
            .map_err(|e| InstallError::Rpc(Box::new(e)))?;

        let current_nonce = provider
            .get_transaction_count(authority_addr)
            .await
            .map_err(|e| InstallError::Rpc(Box::new(e)))?;

        // Self-install: the outer tx bumps the authority's nonce before the
        // authorization list is applied, so the auth must carry `nonce + 1`.
        let auth = Authorization {
            chain_id: U256::from(chain_id),
            address: target,
            nonce: current_nonce + 1,
        };

        let sig = authority.sign_hash(&auth.signature_hash()).await?;
        let signed_auth = auth.into_signed(sig);

        // Set the tx nonce explicitly. When the authority signs the auth
        // itself (this self-install flow), each tx causes TWO nonce bumps on
        // the authority — one for the tx and one for the applied auth — but
        // alloy's default NonceFiller only tracks the tx bump. Bypassing the
        // filler avoids "nonce too low" on subsequent installs.
        Ok(<N::TransactionRequest as Default>::default()
            .with_to(authority_addr)
            .with_nonce(current_nonce)
            .with_authorization_list(vec![signed_auth]))
    }

    /// Install the delegation. Convenience on top of [`prepare_install_tx`](Self::prepare_install_tx):
    /// prepares the tx, sends it, and on the Arbitrum-style "intrinsic gas
    /// too low" / "gas uint64 overflow" failures retries once with an
    /// explicit fallback gas limit.
    ///
    /// `authority` must wrap the same key as the provider's default signer;
    /// returns [`InstallError::AuthorityMismatch`] otherwise. If the resolved
    /// target has no code on-chain, returns
    /// [`InstallError::TridentAccountNotDeployed`] unless auto-deploy applies.
    pub async fn install<P, N, S>(
        self,
        provider: P,
        authority: &S,
    ) -> Result<PendingTransactionBuilder<N>, InstallError>
    where
        P: Provider<N> + WalletProvider<N>,
        N: Network,
        <N as Network>::TransactionRequest: TransactionBuilder7702 + Clone,
        S: Signer + Send + Sync,
    {
        let tx = self.prepare_install_tx(&provider, authority).await?;

        match provider.send_transaction(tx.clone()).await {
            Ok(pending) => Ok(pending),
            Err(err) if should_retry_install_with_explicit_gas(&err) => provider
                .send_transaction(tx.with_gas_limit(INSTALL_GAS_LIMIT_FALLBACK))
                .await
                .map_err(|e| InstallError::Rpc(Box::new(e))),
            Err(err) => Err(InstallError::Rpc(Box::new(err))),
        }
    }
}

fn install_tx_with_gas_retry_message(message: &str) -> bool {
    let message = message.to_ascii_lowercase();
    message.contains("intrinsic gas too low") || message.contains("gas uint64 overflow")
}

fn should_retry_install_with_explicit_gas(err: &(dyn std::error::Error + 'static)) -> bool {
    let mut current = Some(err);
    while let Some(err) = current {
        if install_tx_with_gas_retry_message(&err.to_string()) {
            return true;
        }
        current = err.source();
    }
    false
}

#[cfg(test)]
mod tests {
    use alloy_eips::eip7702::constants::EIP7702_DELEGATION_DESIGNATOR;
    use alloy_network::{AnyNetwork, EthereumWallet};
    use alloy_primitives::Bytes;
    use alloy_provider::ProviderBuilder;
    use alloy_signer_local::PrivateKeySigner;

    use super::*;
    use crate::{abi::TridentAccount, test_util::TestSetup};

    #[test]
    fn install_gas_retry_message_matches_arbitrum_failures() {
        assert!(install_tx_with_gas_retry_message(
            "server returned an error response: error code -32000: gas uint64 overflow"
        ));
        assert!(install_tx_with_gas_retry_message(
            "Rpc(ErrorResp(ErrorPayload { code: -32000, message: \"intrinsic gas too low\", data: None }))"
        ));
        assert!(!install_tx_with_gas_retry_message("nonce too low"));
    }

    #[tokio::test]
    async fn install_rejects_authority_mismatched_with_provider_signer() {
        let setup = TestSetup::new().await;
        let provider = setup.provider();
        // Fresh signer — its address does NOT match the provider's wallet.
        let other_signer = PrivateKeySigner::random();
        assert_ne!(setup.signer.address(), other_signer.address());

        let err = SelfInstaller::new()
            .install(provider, &other_signer)
            .await
            .expect_err("should reject mismatched authority");

        match err {
            InstallError::AuthorityMismatch {
                authority,
                provider_signer: ps,
            } => {
                assert_eq!(authority, other_signer.address());
                assert_eq!(ps, setup.signer.address());
            }
            other => panic!("expected AuthorityMismatch, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn install_auto_deploys_and_delegates_to_default_address() {
        let setup = TestSetup::new().await;
        let provider = setup.provider();
        let authority = setup.signer.address();

        SelfInstaller::new()
            .install(provider.clone(), &setup.signer)
            .await
            .expect("install")
            .get_receipt()
            .await
            .expect("install confirmed");

        // Auto-deploy should have placed `TridentAccount` at DEFAULT_ADDRESS.
        let target = DEFAULT_ADDRESS;
        let deployed_code = provider
            .get_code_at(target)
            .await
            .expect("get_code_at target");
        assert!(
            !deployed_code.is_empty(),
            "`TridentAccount` should be deployed at DEFAULT_ADDRESS"
        );

        // Authority's code is the 23-byte delegation designator pointing at DEFAULT_ADDRESS.
        let code = provider
            .get_code_at(authority)
            .await
            .expect("get_code_at authority");
        assert_eq!(code.len(), 23, "delegation designator is 23 bytes");
        assert_eq!(&code[..3], &EIP7702_DELEGATION_DESIGNATOR);
        assert_eq!(&code[3..], target.as_slice());
    }

    #[tokio::test]
    async fn execute_reverts_with_not_self_when_called_by_another_sender() {
        // Install the delegation on A.
        let setup = TestSetup::new().await;
        let authority = setup.signer.address();
        SelfInstaller::new()
            .install(setup.provider(), &setup.signer)
            .await
            .expect("install")
            .get_receipt()
            .await
            .expect("install confirmed");

        // A second funded anvil account — distinct from A.
        let signer_b: PrivateKeySigner = setup.anvil.keys()[1].clone().into();
        assert_ne!(signer_b.address(), authority);

        let provider_b = ProviderBuilder::new()
            .network::<AnyNetwork>()
            .with_simple_nonce_management()
            .wallet(EthereumWallet::from(signer_b))
            .connect_http(setup.anvil.endpoint_url());

        // eth_call execute on A from B — must revert with NotSelf, since
        // msg.sender (B) != address(this) (A).
        let err = TridentAccount::new(authority, provider_b)
            .execute(Address::ZERO, Bytes::new())
            .call()
            .await
            .expect_err("expected revert: B is not the account");

        assert!(
            matches!(
                err.as_decoded_interface_error::<TridentAccount::TridentAccountErrors>(),
                Some(TridentAccount::TridentAccountErrors::NotSelf(_))
            ),
            "expected NotSelf, got {err:?}"
        );
    }

    #[tokio::test]
    async fn install_errors_when_auto_deploy_disabled_and_target_missing() {
        let setup = TestSetup::new().await;
        let provider = setup.provider();

        let err = SelfInstaller::new()
            .without_deploy_if_missing()
            .install(provider, &setup.signer)
            .await
            .expect_err("should reject missing `TridentAccount`");

        match err {
            InstallError::TridentAccountNotDeployed { target } => {
                assert_eq!(target, DEFAULT_ADDRESS);
            }
            other => panic!("expected TridentAccountNotDeployed, got {other:?}"),
        }
    }
}