arb_rpc/
receipt.rs

1//! Arbitrum receipt conversion for RPC responses.
2
3use alloy_consensus::{Receipt, ReceiptEnvelope, ReceiptWithBloom, TxReceipt, Typed2718};
4use alloy_primitives::{Address, Bloom, TxKind};
5use alloy_rpc_types_eth::TransactionReceipt;
6use alloy_serde::WithOtherFields;
7use arb_primitives::ArbPrimitives;
8use reth_primitives_traits::SealedBlock;
9use reth_rpc_convert::transaction::{ConvertReceiptInput, ReceiptConverter};
10use reth_rpc_eth_types::EthApiError;
11
12use crate::header::l1_block_number_from_mix_hash;
13
14/// Converts Arbitrum receipts to RPC transaction receipts with extension fields.
15#[derive(Debug, Clone)]
16pub struct ArbReceiptConverter;
17
18impl ReceiptConverter<ArbPrimitives> for ArbReceiptConverter {
19    type RpcReceipt = WithOtherFields<TransactionReceipt>;
20    type Error = EthApiError;
21
22    fn convert_receipts(
23        &self,
24        receipts: Vec<ConvertReceiptInput<'_, ArbPrimitives>>,
25    ) -> Result<Vec<Self::RpcReceipt>, EthApiError> {
26        // Without the block we cannot read mix_hash[25] for CollectTips;
27        // assume false (matches arbreth's behaviour up to v60).
28        let results = receipts
29            .into_iter()
30            .map(|input| convert_single_receipt(input, None, false))
31            .collect();
32        Ok(results)
33    }
34
35    fn convert_receipts_with_block(
36        &self,
37        receipts: Vec<ConvertReceiptInput<'_, ArbPrimitives>>,
38        block: &SealedBlock<alloy_consensus::Block<arb_primitives::ArbTransactionSigned>>,
39    ) -> Result<Vec<Self::RpcReceipt>, Self::Error> {
40        let mix_hash = block.header().mix_hash;
41        let l1_block_number = l1_block_number_from_mix_hash(&mix_hash);
42        // mix_hash[16:24] = ArbOSFormatVersion (BE uint64);
43        // mix_hash[25] bit 0 = CollectTips (post-v9 encoding).
44        let arbos_version = u64::from_be_bytes(mix_hash.0[16..24].try_into().unwrap_or_default());
45        // Pre-v10 header (ArbosVersionCollectTipsOld = v9) always means
46        // CollectTips=true regardless of mix_hash[25].
47        let collect_tips = arbos_version
48            == arb_chainspec::arbos_version::ARBOS_VERSION_COLLECT_TIPS_OLD
49            || (mix_hash.0[25] & 1) == 1;
50
51        let results = receipts
52            .into_iter()
53            .map(|input| convert_single_receipt(input, Some(l1_block_number), collect_tips))
54            .collect();
55        Ok(results)
56    }
57}
58
59fn convert_single_receipt(
60    input: ConvertReceiptInput<'_, ArbPrimitives>,
61    l1_block_number: Option<u64>,
62    collect_tips: bool,
63) -> WithOtherFields<TransactionReceipt> {
64    use alloy_consensus::{Transaction, transaction::TxHashRef};
65
66    let ConvertReceiptInput {
67        receipt,
68        tx,
69        gas_used,
70        next_log_index,
71        meta,
72    } = input;
73
74    let from = tx.signer();
75    let tx_hash = *tx.tx_hash();
76    let tx_type = tx.ty();
77
78    let (contract_address, to) = match tx.kind() {
79        TxKind::Create => (Some(from.create(tx.nonce())), None),
80        TxKind::Call(addr) => (None, Some(Address(*addr))),
81    };
82
83    let cumulative_gas_used = receipt.cumulative_gas_used();
84    let status = receipt.status_or_post_state();
85    let gas_used_for_l1 = receipt.gas_used_for_l1;
86
87    // Convert primitive logs to RPC logs with block/tx metadata.
88    let rpc_logs: Vec<alloy_rpc_types_eth::Log> = receipt
89        .logs()
90        .iter()
91        .enumerate()
92        .map(|(i, log)| alloy_rpc_types_eth::Log {
93            inner: log.clone(),
94            block_hash: Some(meta.block_hash),
95            block_number: Some(meta.block_number),
96            block_timestamp: None,
97            transaction_hash: Some(tx_hash),
98            transaction_index: Some(meta.index),
99            log_index: Some(next_log_index as u64 + i as u64),
100            removed: false,
101        })
102        .collect();
103
104    let bloom: Bloom = receipt.logs().iter().collect();
105
106    let receipt_with_bloom = ReceiptWithBloom::new(
107        Receipt {
108            status,
109            cumulative_gas_used,
110            logs: rpc_logs,
111        },
112        bloom,
113    );
114
115    // Build envelope matching transaction type.
116    let envelope = match tx_type {
117        0x01 => ReceiptEnvelope::Eip2930(receipt_with_bloom),
118        0x02 => ReceiptEnvelope::Eip1559(receipt_with_bloom),
119        0x03 => ReceiptEnvelope::Eip4844(receipt_with_bloom),
120        0x04 => ReceiptEnvelope::Eip7702(receipt_with_bloom),
121        _ => ReceiptEnvelope::Legacy(receipt_with_bloom),
122    };
123
124    // effective_gas_price: when CollectTips is set, use the per-tx-type
125    // formula; otherwise return the block base fee.
126    let base_fee = meta.base_fee.unwrap_or(0) as u128;
127    let effective_gas_price = if collect_tips {
128        match tx_type {
129            // Legacy + EIP-2930: stored gas price.
130            0x00 | 0x01 => tx.gas_price().unwrap_or(base_fee),
131            // ArbitrumDepositTx, ArbitrumInternalTx: always 0.
132            0x64 | 0x6A => 0,
133            // ArbitrumUnsignedTx, ArbitrumContractTx, ArbitrumRetryTx,
134            // ArbitrumSubmitRetryableTx: baseFee.
135            0x65 | 0x66 | 0x68 | 0x69 => base_fee,
136            // EIP-1559, EIP-4844, EIP-7702: min(maxFeePerGas, baseFee + tipCap).
137            _ => {
138                let tip = tx.max_priority_fee_per_gas().unwrap_or(0);
139                let cap = tx.max_fee_per_gas();
140                base_fee.saturating_add(tip).min(cap)
141            }
142        }
143    } else {
144        base_fee
145    };
146
147    let base_receipt = TransactionReceipt {
148        inner: envelope,
149        transaction_hash: tx_hash,
150        transaction_index: Some(meta.index),
151        block_hash: Some(meta.block_hash),
152        block_number: Some(meta.block_number),
153        gas_used,
154        effective_gas_price,
155        blob_gas_used: None,
156        blob_gas_price: None,
157        from,
158        to,
159        contract_address,
160    };
161
162    // Add Arbitrum-specific extension fields.
163    let mut other = std::collections::BTreeMap::new();
164
165    // Override `type` for Arbitrum tx types (0x64+) since ReceiptEnvelope
166    // only supports standard Ethereum types and falls back to Legacy (0x0).
167    if tx_type >= 0x64 {
168        other.insert(
169            "type".to_string(),
170            serde_json::to_value(format!("{tx_type:#x}")).unwrap_or_default(),
171        );
172    }
173
174    // gasUsedForL1: always present on Arbitrum receipts.
175    other.insert(
176        "gasUsedForL1".to_string(),
177        serde_json::to_value(format!("{:#x}", gas_used_for_l1)).unwrap_or_default(),
178    );
179
180    // l1BlockNumber: included when block header is available.
181    if let Some(l1_bn) = l1_block_number {
182        other.insert(
183            "l1BlockNumber".to_string(),
184            serde_json::to_value(format!("{l1_bn:#x}")).unwrap_or_default(),
185        );
186    }
187
188    // multiGasUsed: multi-dimensional gas breakdown.
189    if !receipt.multi_gas_used.is_zero() {
190        other.insert(
191            "multiGasUsed".to_string(),
192            serde_json::to_value(receipt.multi_gas_used).unwrap_or_default(),
193        );
194    }
195
196    WithOtherFields {
197        inner: base_receipt,
198        other: alloy_serde::OtherFields::new(other),
199    }
200}