fix(anvil): preserve tempo calls in tx requests#15804
Conversation
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.
grandizzy
left a comment
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
This link points from public documentation to a private constant and currently fails the docs job under rustdoc -D warnings.
| /// - **Tempo**: When any Tempo-specific field ([`TEMPO_REQUEST_KEYS`]) is present, or transaction | |
| /// - **Tempo**: When any Tempo-specific field is present, or transaction |
23fb306 to
49dcf41
Compare
anvil --network temposilently dropscalls.Minimal repro against
anvil --network tempo: