Skip to content

fix(anvil): preserve tempo calls in tx requests#15804

Open
jxom wants to merge 1 commit into
foundry-rs:masterfrom
jxom:jxom/anvil-tempo-calls
Open

fix(anvil): preserve tempo calls in tx requests#15804
jxom wants to merge 1 commit into
foundry-rs:masterfrom
jxom:jxom/anvil-tempo-calls

Conversation

@jxom

@jxom jxom commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

anvil --network tempo silently drops calls.

Minimal repro against anvil --network tempo:

cast rpc eth_sendTransaction '{
  "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
  "type": "0x76",
  "calls": [
    {"to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "data": "0xdead"},
    {"to": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", "data": "0xbeef"}
  ]
}'

Round-trip eth_sendTransaction requests into TempoTransactionRequest,
keeping all tempo extension fields (calls, keyType, ...). Skip the
default kind fill for batch requests. Build the tempo batch call env
from calls in eth_call and eth_estimateGas. Keep the full batch when
converting typed txs back to requests.
@jxom
jxom marked this pull request as ready for review July 17, 2026 10:02

@grandizzy grandizzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core batch fix looks correct: eth_sendTransaction preserves the complete calls array, estimation executes the batch, and call ordering matches Tempo.

Please check the findings below.

other.insert("nonceKey".to_string(), serde_json::to_value(tx.nonce_key).unwrap());
let first_call = tx.calls.first();
// Carry the full batch; `to` stays empty as `build_aa` appends it as a call.
other.insert("calls".to_string(), serde_json::to_value(&tx.calls).unwrap());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This manual conversion drops validBefore, validAfter, aaAuthorizationList, keyAuthorization, and feePayerSignature.

Replace the entire Tempo arm with Tempo’s existing complete conversion:

FoundryTypedTx::Tempo(tx) => Self::Tempo(Box::new(tx.into())),

TempoTransactionRequest::from(TempoTransaction) already preserves every transaction field and keeps to, value, and input unset so calls are not duplicated.

// Serde round-trip to pick up all Tempo fields carried in `other`.
let tempo_tx_req = serde_json::to_value(&tx)
.and_then(serde_json::from_value::<TempoTransactionRequest>)
.unwrap_or_else(|_| tx.inner.into());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the fallback to tx.inner.into(). It accepts malformed Tempo fields as a different, default-like transaction. For example, {"type":"0x76","calls":"not-an-array"} currently succeeds with an empty calls list.

Replace this infallible conversion with a fallible RPC conversion:

impl FoundryTransactionRequest {
    pub fn try_from_rpc_request(
        tx: WithOtherFields<TransactionRequest>,
    ) -> Result<Self, serde_json::Error> {
        if tx.transaction_type == Some(TEMPO_TX_TYPE_ID)
            || TEMPO_REQUEST_KEYS.iter().any(|key| tx.other.contains_key(*key))
        {
            let tempo = serde_json::to_value(&tx)
                .and_then(serde_json::from_value::<TempoTransactionRequest>)?;
            return Ok(Self::Tempo(Box::new(tempo)));
        }

        #[cfg(feature = "optimism")]
        if tx.transaction_type == Some(DEPOSIT_TX_TYPE_ID)
            || tx.transaction_type == Some(POST_EXEC_TX_TYPE_ID)
            || get_deposit_tx_parts(&tx.other).is_ok()
        {
            return Ok(Self::Op(tx));
        }

        Ok(Self::Ethereum(tx.into_inner()))
    }
}

Use it at RPC ingress and map the error to RpcError::invalid_params. Also use it from Deserialize with serde::de::Error::custom, and remove or migrate the infallible From<WithOtherFields<TransactionRequest>> implementation so malformed user-controlled fields cannot reach the fallback path.

.other
.get_deserialized::<Vec<Call>>("calls")
.and_then(|r| r.ok())
.unwrap_or_default();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This manual mapping is incomplete. It ignores keyType, keyData, keyId, aaAuthorizationList, keyAuthorization, and feePayerSignature, and it does not treat feeToken alone as an AA request.

Factor the body of Tempo’s existing TryIntoTxEnv implementation into a lightweight, Reth-independent API:

impl TempoTransactionRequest {
    pub fn try_into_tempo_tx_env(
        self,
        inner: TxEnv,
        hardfork: TempoHardfork,
    ) -> Result<TempoTxEnv, TempoTxEnvError> {
        // AA classification, call ordering, mock signatures,
        // authorization, validity, fee token, and fee payer handling.
    }
}

Tempo’s Reth adapter should call this helper after constructing the base TxEnv. Anvil should parse the complete TempoTransactionRequest, build its base TxEnv, and pass both into this helper instead of extracting extension fields individually. Use the same helper in call_with_state, tracing, access-list generation, and eth_simulateV1 so every simulation RPC executes the same Tempo transaction.

let Ok(FoundryTypedTx::Tempo(rebuilt)) = req.build_unsigned() else {
panic!("expected Tempo typed tx");
};
assert_eq!(rebuilt.calls, calls);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only checks calls, so it passes while other Tempo fields are dropped. Set non-empty valid_before and valid_after, clone tempo_tx when constructing the request, and compare the complete rebuilt transaction:

let tempo_tx = TempoTransaction {
    chain_id: 1,
    max_fee_per_gas: 1_000_000,
    max_priority_fee_per_gas: 1_000_000,
    gas_limit: 1_000_000,
    calls,
    valid_before: NonZeroU64::new(123_456),
    valid_after: NonZeroU64::new(123_000),
    ..Default::default()
};

let request: FoundryTransactionRequest =
    FoundryTypedTx::Tempo(tempo_tx.clone()).into();

let Ok(FoundryTypedTx::Tempo(rebuilt)) = request.build_unsigned() else {
    panic!("expected Tempo typed tx");
};

assert_eq!(rebuilt, tempo_tx);

Full equality will also protect authorization, key-authorization, and fee-payer fields when populated fixtures are added.

/// `DEPOSIT_TX_TYPE_ID`
/// - **Tempo**: When `feeToken` or `nonceKey` fields are present, or transaction type is
/// `TEMPO_TX_TYPE_ID`
/// - **Tempo**: When any Tempo-specific field ([`TEMPO_REQUEST_KEYS`]) is present, or transaction

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This link points from public documentation to a private constant and currently fails the docs job under rustdoc -D warnings.

Suggested change
/// - **Tempo**: When any Tempo-specific field ([`TEMPO_REQUEST_KEYS`]) is present, or transaction
/// - **Tempo**: When any Tempo-specific field is present, or transaction

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants