Detailed Walkthrough

Step 1: Set up EIP-7702 Account

Why EIP-7702 is Required: DA Builder calls your account address as if it were a contract implementing the IProposer interface. This allows DA Builder to execute transactions on your behalf while maintaining your account's ability to pass whitelists and other access controls.

The IProposer interface is simple:

interface IProposer {
    function call(address _target, bytes calldata _data, uint256 _value) external returns (bool);
}

This function allows DA Builder to execute any transaction on your behalf. The security comes from the fact that only DA Builder's ProposerMulticall contract can call this function.

Step 2: Deploy Proposer Contract Implementation

Create a Forge project and deploy a proposer contract. You have several security options:

Important: We recommend using the TrustlessProposer for production deployments as it provides the strongest security guarantees by requiring cryptographic signatures for each transaction.

Trustless Proposer (Recommended)

For better security, use a trustless proposer that requires signatures. This implementation expects the _data parameter to contain a signature and additional metadata:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {EIP712} from '@openzeppelin/contracts/utils/cryptography/EIP712.sol';

interface IProposer {
    error LowLevelCallFailed();
    error Unauthorized();
    
    function call(address _target, bytes calldata _data, uint256 _value) external returns (bool);
}

/// @title TrustlessProposer
/// @notice A secure proposer implementation that requires cryptographic signatures
/// @dev Uses custom storage layout to prevent conflicts with future account code versions
/// @custom:storage-location keccak256(abi.encode(uint256(keccak256("Spire.TrustlessProposer.1.0.0")) - 1)) & ~bytes32(uint256(0xff))
contract TrustlessProposer is IProposer, EIP712 layout at 25_732_701_950_170_629_563_862_734_149_613_701_595_693_524_766_703_709_478_375_563_609_458_162_252_544 {
    error NonceTooLow();
    error DeadlinePassed();
    error SignatureInvalid();

    bytes32 public constant CALL_TYPEHASH =
        keccak256('Call(uint256 deadline,uint256 nonce,address target,uint256 value,bytes calldata)');

    address public immutable PROPOSER_MULTICALL;
    uint256 public nestedNonce;

    constructor(address _proposerMulticall) EIP712('TrustlessProposer', '1') {
        PROPOSER_MULTICALL = _proposerMulticall;
    }

    function call(address _target, bytes calldata _data, uint256 _value) external returns (bool) {
        if (msg.sender != PROPOSER_MULTICALL && address(this) != msg.sender) revert Unauthorized();

        // Decode the data parameter which contains: (signature, deadline, nonce, actual_calldata)
        (bytes memory _sig, uint256 _deadline, uint256 _nonce, bytes memory _calldata) =
            abi.decode(_data, (bytes, uint256, uint256, bytes));

        if (block.timestamp > _deadline) revert DeadlinePassed();
        if (_nonce != nestedNonce) revert NonceTooLow();

        // Create the EIP-712 message hash
        bytes32 _messageHash = _hashTypedDataV4(
            keccak256(abi.encode(CALL_TYPEHASH, _deadline, _nonce, _target, _value, _calldata))
        );

        // Extract signature components
        uint8 v;
        bytes32 r;
        bytes32 s;
        assembly {
            r := mload(add(_sig, 0x20))
            s := mload(add(_sig, 0x40))
            v := byte(0, mload(add(_sig, 0x60)))
        }

        // Recover the signer from the signature
        address _signer = ecrecover(_messageHash, v, r, s);
        if (_signer != address(this)) revert SignatureInvalid();

        // Execute the actual call
        (bool _success,) = _target.call{value: _value}(_calldata);
        if (!_success) {
            revert LowLevelCallFailed();
        }

        nestedNonce++;
        return true;
    }

    receive() external payable {}
    fallback() external payable {}
}

How TrustlessProposer Works:

  1. Signature Requirement: When DA Builder calls your proposer, the _data parameter must contain a signature that you created offline

  2. Data Structure: The _data parameter is encoded as: abi.encode(signature, deadline, nonce, actual_calldata)

  3. Signature Verification: The contract recovers the signer from the signature and verifies it matches your EOA address

  4. Nonce Protection: Each call requires a unique nonce to prevent replay attacks

  5. Deadline Protection: Each call has a deadline to prevent stale transactions

Security Benefits: This implementation requires you to cryptographically sign each transaction before da-builder can execute it, providing strong guarantees that only you can authorize transactions.

Important: Using TrustlessProposer requires custom transaction encoding on your end to include the signature in the _data parameter. This is more complex but provides the strongest security guarantees. Custom proposer implementations could avoid these kinds of wrapped transactions if you are willing to allow DA Builder to execute any transaction on your behalf.

Custom Storage Layout: This contract uses a fixed storage location (layout at) to prevent storage conflicts when upgrading account code. Since EIP-7702 account code can be updated but storage persists, using a predictable storage layout ensures that the nestedNonce variable won't conflict with future or past account code versions that might use different storage layouts. See here for more details.

Create a deployment script:

// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {Script} from "forge-std/Script.sol";

contract DeployScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        address proposerMulticall = vm.envAddress("PROPOSER_MULTICALL_ADDRESS");
        
        vm.startBroadcast(deployerPrivateKey);
        
        // Deploy the TrustlessProposer contract
        TrustlessProposer proposer = new TrustlessProposer(proposerMulticall);
        
        vm.stopBroadcast();
        
        console.log("TrustlessProposer deployed at:", address(proposer));
    }
}

Deploy the contract:

export PRIVATE_KEY=your_private_key
export PROPOSER_MULTICALL_ADDRESS=0x... # Get this from DA Builder team
forge script script/Deploy.s.sol --rpc-url https://da-build-holesky.spire.dev --broadcast

Step 3: Create and Submit EIP-7702 Transaction

Use reth/alloy to create and submit the EIP-7702 transaction:

use alloy::eips::eip7702::SignedAuthorization;
use alloy::network::{Ethereum, EthereumWallet, TransactionBuilder};
use alloy::primitives::{Address, Bytes, U256};
use alloy::rpc::types::TransactionRequest;
use alloy::signers::local::PrivateKeySigner;

async fn setup_eip7702_account(
    private_key: &str,
    proposer_address: Address,
    rpc_url: &str,
) -> eyre::Result<()> {
    // Create signer
    let signer = PrivateKeySigner::from_str(private_key)?;
    let wallet = EthereumWallet::from(signer);
    
    // Get current nonce
    let provider = Provider::<Http>::try_from(rpc_url)?;
    let address = wallet.address();
    let nonce = provider.get_transaction_count(address, None).await?;
    
    // Create authorization data
    let authorization = SignedAuthorization {
        account: address,
        contract_code: proposer_address,
        authorization_data: Bytes::new(), // Empty for basic setup
    };
    
    // Create EIP-7702 transaction
    let mut tx = TransactionRequest {
        nonce: Some(nonce.as_u64()),
        value: Some(U256::from(0)),
        to: None, // No recipient for EIP-7702 setup
        gas: Some(500_000),
        max_fee_per_gas: Some(20e9 as u128),
        max_priority_fee_per_gas: Some(2e9 as u128),
        chain_id: Some(17000), // Holesky
        input: TransactionInput::default(),
        authorization_list: Some(vec![authorization]),
        transaction_type: Some(0x7702), // EIP-7702 transaction type
        ..Default::default()
    };
    
    // Sign and submit transaction
    let signed = wallet.sign_request(tx).await?;
    let tx_hash = provider.send_raw_transaction(signed.encoded_2718().into()).await?;
    
    println!("EIP-7702 transaction submitted: {:?}", tx_hash);
    
    // Wait for confirmation
    provider.wait_for_transaction(tx_hash, None, None).await?;
    println!("EIP-7702 setup completed!");
    
    Ok(())
}

Step 4: Fund Your Account

Deposit funds into the GasTank to cover gas costs:

use alloy::primitives::U256;

async fn fund_gas_tank(
    private_key: &str,
    amount: U256,
    gas_tank_address: Address,
    rpc_url: &str,
) -> eyre::Result<()> {
    let signer = PrivateKeySigner::from_str(private_key)?;
    let wallet = EthereumWallet::from(signer);
    let provider = Provider::<Http>::try_from(rpc_url)?;
    
    // Get current nonce
    let nonce = provider.get_transaction_count(wallet.address(), None).await?;
    
    // Create deposit transaction to GasTank
    let tx = TransactionRequest {
        nonce: Some(nonce.as_u64()),
        value: Some(amount),
        to: Some(gas_tank_address), // GasTank contract address
        gas: Some(21_000),
        max_fee_per_gas: Some(20e9 as u128),
        max_priority_fee_per_gas: Some(2e9 as u128),
        chain_id: Some(17000),
        ..Default::default()
    };
    
    let signed = wallet.sign_request(tx).await?;
    let tx_hash = provider.send_raw_transaction(signed.encoded_2718().into()).await?;
    
    println!("GasTank deposit transaction submitted: {:?}", tx_hash);
    provider.wait_for_transaction(tx_hash, None, None).await?;
    
    Ok(())
}

Step 5: Submit Transactions

Submit transactions to DA Builder's RPC endpoint using the TrustlessProposer. This requires creating a signature offline before submitting.

use alloy::consensus::{SidecarBuilder, SimpleCoder};
use alloy::eips::eip2718::Encodable2718;
use alloy::eips::eip712::{Eip712Domain, TypedData};
use alloy::primitives::{keccak256, B256};

async fn submit_blob_transaction_trustless(
    private_key: &str,
    data: &str,
    rpc_url: &str
) -> eyre::Result<()> {
    let signer = PrivateKeySigner::from_str(private_key)?;
    let wallet = EthereumWallet::from(signer);
    let eoa_address = wallet.address();
    let provider = Provider::<Http>::try_from(rpc_url)?;
    
    // Create blob sidecar
    let mut builder = SidecarBuilder::<SimpleCoder>::new();
    builder.ingest(data.as_bytes());
    let sidecar = builder.build()?;
    
    // Get current nonce from your EOA (which now has proposer code)
    let proposer_contract = IProposer::new(eoa_address, provider.clone());
    let nested_nonce = proposer_contract.nestedNonce().call().await?;
    
    // Create the call parameters that will be executed by the proposer
    let target = Address::ZERO; // Example target
    let value = U256::from(0);
    let calldata = Bytes::new(); // Example calldata
    
    // Set deadline (e.g., 1 hour from now)
    let deadline = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)?
        .as_secs() + 3600;
    
    // Create the message hash exactly as the contract does
    let call_type_hash = keccak256("Call(uint256 deadline,uint256 nonce,address target,uint256 value,bytes calldata)");
    let encoded_data = abi::encode(&[
        call_type_hash.into(),
        deadline.into(),
        nested_nonce.into(),
        target.into(),
        value.into(),
        calldata.into(),
    ]);
    
    // Create EIP-712 domain separator
    let domain_separator = keccak256(abi::encode(&[
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)").into(),
        keccak256("TrustlessProposer").into(),
        keccak256("1").into(),
        17000u64.into(), // chain_id
        eoa_address.into(), // verifying contract (your EOA)
    ]));
    
    // Create the final message hash
    let message_hash = keccak256(abi::encode(&[
        "\x19\x01".as_bytes().into(),
        domain_separator.into(),
        keccak256(encoded_data).into(),
    ]));
    
    // Sign the message hash
    let signature = wallet.sign_hash(message_hash).await?;
    
    // Encode the data for the proposer call: (signature, deadline, nonce, actual_calldata)
    let proposer_data = abi::encode(&[
        signature.into(), // signature
        deadline.into(),  // deadline
        nested_nonce.into(), // nonce
        calldata.into(),  // actual calldata
    ]);
    
    // Get nonce for the main transaction
    let nonce = provider.get_transaction_count(wallet.address(), None).await?;
    
    // Create the main transaction (this will be the blob transaction)
    // The transaction is sent to your EOA address, which now has proposer code
    let mut tx = TransactionRequest {
        nonce: Some(nonce.as_u64()),
        value: Some(U256::from(0)),
        to: Some(eoa_address), // Send to your EOA (which has proposer code)
        gas: Some(200_000),
        max_fee_per_gas: Some(20e9 as u128),
        max_priority_fee_per_gas: Some(2e9 as u128),
        max_fee_per_blob_gas: Some(15e9 as u128),
        chain_id: Some(17000),
        transaction_type: Some(0x03), // EIP-4844
        input: TransactionInput::from(proposer_data), // This contains the signature and metadata
        ..Default::default()
    };
    
    // Attach blob sidecar
    tx.set_blob_sidecar(sidecar);
    
    // Sign and submit
    let signed = wallet.sign_request(tx).await?;
    let tx_hash = provider.send_raw_transaction(signed.encoded_2718().into()).await?;
    
    println!("Trustless blob transaction submitted: {:?}", tx_hash);
    
    Ok(())
}

Key Differences for TrustlessProposer:

  1. Offline Signature: You create an EIP-712 signature offline before submitting

  2. Nonce Management: You need to track the proposer's nestedNonce

  3. Data Encoding: The transaction's data field contains the signature and metadata, not the actual calldata

  4. Security: Only you can create valid signatures, preventing unauthorized execution

Step 6: Track Transaction Status

Monitor your transactions:

async fn track_transaction(
    transaction_id: B256,
    rpc_url: &str,
) -> eyre::Result<()> {
    let provider = Provider::<Http>::try_from(rpc_url)?;
    
    // Poll for transaction receipt
    let mut attempts = 0;
    let max_attempts = 60;
    
    while attempts < max_attempts {
        match provider.get_transaction_receipt(transaction_id).await {
            Ok(Some(receipt)) => {
                println!("Transaction included in block: {}", receipt.block_number);
                println!("Status: {}", if receipt.status { "success" } else { "failed" });
                println!("Gas used: {}", receipt.gas_used);
                return Ok(());
            }
            Ok(None) => {
                println!("Transaction not yet included... (attempt {})", attempts + 1);
            }
            Err(e) => {
                println!("Error checking transaction: {}", e);
            }
        }
        
        attempts += 1;
        tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
    }
    
    Err(eyre::eyre!("Transaction not included within expected timeframe"))
}

Testing Your Integration

Test your integration with a complete example:

#[tokio::main]
async fn main() -> eyre::Result<()> {
    let private_key = "your_private_key_here";
    let proposer_address = Address::from_str("deployed_proposer_address")?;
    let gas_tank_address = Address::from_str("gas_tank_address")?; // Get from DA Builder team
    let rpc_url = "https://da-build-holesky.spire.dev";
    
    // Step 1: Set up EIP-7702 account
    setup_eip7702_account(private_key, proposer_address, rpc_url).await?;
    
    // Step 2: Fund GasTank account
    fund_gas_tank(private_key, U256::from(1e18), gas_tank_address, rpc_url).await?;
    
    // Step 3: Submit test transaction
    let test_data = "Hello, da-builder!";
    submit_blob_transaction_trustless(private_key, test_data, rpc_url).await?;
    
    println!("Integration test completed successfully!");
    
    Ok(())
}

Important Details

Security Model

The architecture provides multiple layers of security:

  1. EIP-7702 Authorization: Only your EOA can authorize the proposer contract code

  2. ProposerMulticall Restriction: Only da-builder's ProposerMulticall contract can call your proposer

  3. Authorization List: The EIP-7702 transaction creates a permanent association between your EOA and the proposer contract

  4. Trustless Proposer: Requires cryptographic signatures for each transaction, providing strong security guarantees that only you can authorize transactions. Custom proposer implementations are possible and can impact security guarantees be careful when implementing your own.

Transaction Tracking

When you submit a transaction to DA Builder, you receive a transaction ID (not a blockchain hash). This ID is used to track your transaction through our system.

Key Points:

  • The transaction ID returned by eth_sendRawTransaction is not a blockchain transaction hash

  • Use this ID with eth_getTransactionReceipt to track your transaction

  • Once your transaction is included in a block, the receipt will contain the actual blockchain transaction hash

Last updated