diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs index 317d750a7291..c41438b35d84 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs @@ -1424,6 +1424,13 @@ impl pallet_revive::Config for Runtime { type AutoMap = ConstBool; type GasScale = ConstU32<1000>; type OnBurn = Dap; + // Sized above the per-block ceiling on mirrored logs. That ceiling is PoV-bound: the normal + // dispatch budget (75% * MAX_POV_SIZE = 0.75 * 10 MiB ~= 7.86 MB) divided by one + // `assets::transfer`'s proof size (~6208 B) is ~1267 transfers, i.e. ~1267 logs. 1300 clears it + // with headroom so legitimate logs are not dropped. `on_initialize` reserves this cap times the + // benchmarked per-log drain weight (`on_finalize_per_outside_frame_log`, PoV-measured), so a + // larger cap does cost reserved block weight every block. + type MaxOutsideFrameLogs = ConstU32<1300>; type Deposit = pallet_revive::PGasDeposit< Runtime, Assets, diff --git a/cumulus/parachains/runtimes/testing/penpal/src/lib.rs b/cumulus/parachains/runtimes/testing/penpal/src/lib.rs index eb2473846f45..a934c4422eb2 100644 --- a/cumulus/parachains/runtimes/testing/penpal/src/lib.rs +++ b/cumulus/parachains/runtimes/testing/penpal/src/lib.rs @@ -798,6 +798,8 @@ impl pallet_revive::Config for Runtime { type AutoMap = ConstBool; type GasScale = ConstU32<1000>; type OnBurn = (); + // The ERC-20 Transfer log callback is not wired here, so nothing buffers outside-of-frame logs. + type MaxOutsideFrameLogs = ConstU32<0>; type Deposit = (); } diff --git a/prdoc/pr_12581.prdoc b/prdoc/pr_12581.prdoc index bc42aaf55377..8336a31a860c 100644 --- a/prdoc/pr_12581.prdoc +++ b/prdoc/pr_12581.prdoc @@ -29,6 +29,11 @@ doc: the placeholder weight leaves it effectively unmetered. - Foreign assets: the inline-only callback does not cover bridged assets; a foreign-id variant is needed if those must also surface Transfer logs. + - Native DOT: out of scope here — this PR mirrors pallet-assets only, and native balance lives + in pallet-balances, which has no ERC-20 precompile and thus no token address to attribute a + `Transfer` to. This is a deliberate deferral, not a permanent exclusion: it could later be + mirrored by attributing DOT to a fixed pseudo-address, but that carries its own + consumer-expectation cost and needs explicit team sign-off before it is taken on. crates: - name: pallet-assets bump: major diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index 551d7dd408f2..903a9c05d317 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -1594,6 +1594,8 @@ impl pallet_revive::Config for Runtime { type AutoMap = ConstBool; type GasScale = ConstU32<1000>; type OnBurn = (); + // The ERC-20 Transfer log callback is not wired here, so nothing buffers outside-of-frame logs. + type MaxOutsideFrameLogs = ConstU32<0>; type Deposit = (); } diff --git a/substrate/frame/revive/rpc/src/receipt_extractor.rs b/substrate/frame/revive/rpc/src/receipt_extractor.rs index 715bb2397659..7564e5494ac3 100644 --- a/substrate/frame/revive/rpc/src/receipt_extractor.rs +++ b/substrate/frame/revive/rpc/src/receipt_extractor.rs @@ -94,23 +94,31 @@ fn decode_revive_event( } /// Iterate decoded block events and bucket revert flags and logs per extrinsic. -/// Events for other extrinsics are skipped. +/// +/// `ContractEmitted` logs whose extrinsic is not an ethereum transaction (`eth_tx_hash_for` +/// returns `None`) are "outside-of-frame" logs — emitted by a runtime component (e.g. a +/// pallet-assets balance-change mirror) during a plain extrinsic. The runtime aggregates them into +/// one synthetic transaction per block; here they are collected into a separate bucket, tagged with +/// the synthetic transaction's hash and index, and returned alongside the per-extrinsic logs. /// /// Events are stored sequentially without size markers, so a single /// undecodable event (e.g. from a runtime upgrade that shifted variant /// indices) corrupts the offset for all subsequent events. /// Decode errors are logged and skipped to avoid losing the entire receipt. /// -/// Returns `(reverted_extrinsics, logs_by_extrinsic)` keyed by extrinsic index. +/// Returns `(reverted_extrinsics, logs_by_extrinsic, outside_frame_logs)`. fn extract_revive_events( block_events: &subxt::events::Events, substrate_block_number: SubstrateBlockNumber, eth_block_number: U256, eth_block_hash: H256, eth_tx_hash_for: impl Fn(usize) -> Option, -) -> (HashSet, HashMap>) { + synthetic_tx_hash: H256, + synthetic_tx_index: usize, +) -> (HashSet, HashMap>, Vec) { let mut reverted_extrinsics: HashSet = HashSet::new(); let mut logs_by_extrinsic: HashMap> = HashMap::new(); + let mut outside_frame_logs: Vec = Vec::new(); for (event_index, event_result) in block_events.iter().enumerate() { let event = match event_result { @@ -129,26 +137,39 @@ fn extract_revive_events( _ => continue, }; - let Some(eth_tx_hash) = eth_tx_hash_for(extrinsic_index) else { continue }; - - match decode_revive_event( - &event, - eth_block_number, - eth_tx_hash, - extrinsic_index, - eth_block_hash, - ) { - Some(ReviveEvent::Revert) => { - reverted_extrinsics.insert(extrinsic_index); + match eth_tx_hash_for(extrinsic_index) { + Some(eth_tx_hash) => match decode_revive_event( + &event, + eth_block_number, + eth_tx_hash, + extrinsic_index, + eth_block_hash, + ) { + Some(ReviveEvent::Revert) => { + reverted_extrinsics.insert(extrinsic_index); + }, + Some(ReviveEvent::Log(log)) => { + logs_by_extrinsic.entry(extrinsic_index).or_default().push(log); + }, + None => {}, }, - Some(ReviveEvent::Log(log)) => { - logs_by_extrinsic.entry(extrinsic_index).or_default().push(log); + // Not an ethereum transaction: a `ContractEmitted` here is an outside-of-frame log, + // attributed to the block's synthetic transaction. Reverts are meaningless here. + None => { + if let Some(ReviveEvent::Log(log)) = decode_revive_event( + &event, + eth_block_number, + synthetic_tx_hash, + synthetic_tx_index, + eth_block_hash, + ) { + outside_frame_logs.push(log); + } }, - None => {}, } } - (reverted_extrinsics, logs_by_extrinsic) + (reverted_extrinsics, logs_by_extrinsic, outside_frame_logs) } type FetchReceiptDataFn = Arc< @@ -178,6 +199,10 @@ pub struct ReceiptExtractor { /// Recover the ethereum address from a transaction signature. recover_eth_address: RecoverEthAddressFn, + + /// EVM chain id. Used to rebuild the synthetic transaction that carries a block's + /// outside-of-frame logs (its hash must match the one the runtime committed). + chain_id: u64, } impl ReceiptExtractor { @@ -222,11 +247,17 @@ impl ReceiptExtractor { Box::pin(fut) as Pin> }); + let chain_id = { + let query = crate::subxt_client::constants().revive().chain_id().unvalidated(); + api.constants().at(&query)? + }; + Ok(Self { fetch_receipt_data, fetch_eth_block_hash, first_evm_block: Arc::new(AtomicU32::new(u32::MAX)), recover_eth_address: recover_eth_address_fn, + chain_id, }) } @@ -248,6 +279,7 @@ impl ReceiptExtractor { recover_eth_address: Arc::new(|signed_tx: &TransactionSigned| { signed_tx.recover_eth_address() }), + chain_id: 420_420_420, } } @@ -346,6 +378,49 @@ impl ReceiptExtractor { Ok((signed_tx, receipt)) } + /// Rebuild the block's synthetic transaction payload and its hash. Must reproduce the exact + /// bytes the runtime committed, hence the shared [`pallet_revive::evm::synthetic_log_transaction`] + /// keyed by chain id and block number. + fn synthetic_tx(&self, eth_block_number: U256) -> (Vec, H256) { + let payload = pallet_revive::evm::synthetic_log_transaction( + eth_block_number, + U256::from(self.chain_id), + ); + let hash = H256(keccak_256(&payload)); + (payload, hash) + } + + /// Assemble the receipt for the block's synthetic transaction, carrying its outside-of-frame + /// logs. The synthetic transaction has no real sender: `from` is zero and `to` / + /// `contract_address` are `None`. + fn build_synthetic_receipt( + &self, + eth_block_hash: H256, + eth_block_number: U256, + transaction_index: usize, + gas_info: ReceiptGasInfoV1, + logs: Vec, + ) -> Result<(TransactionSigned, ReceiptInfo), ClientError> { + let (payload, transaction_hash) = self.synthetic_tx(eth_block_number); + let signed_tx = + TransactionSigned::decode(&payload).map_err(|_| ClientError::TxDecodingFailed)?; + let receipt = ReceiptInfo::new( + eth_block_hash, + eth_block_number, + None, + H160::zero(), + logs, + None, + gas_info.effective_gas_price, + U256::from(gas_info.gas_used), + true, + transaction_hash, + transaction_index.into(), + Default::default(), + ); + Ok((signed_tx, receipt)) + } + /// Extract receipts from block. pub async fn extract_from_block( &self, @@ -368,33 +443,41 @@ impl ReceiptExtractor { return Ok(vec![]); } - let eth_tx_by_index: BTreeMap = self - .get_block_extrinsics(block) - .await? + let (extrinsics, synthetic_gas_info) = self.get_block_extrinsics(block).await?; + let eth_tx_by_index: BTreeMap = extrinsics + .into_iter() .map(|(call, receipt_gas_info, extrinsic_index)| { let hash = H256(keccak_256(&call.payload)); (extrinsic_index, (call, hash, receipt_gas_info)) }) .collect(); - if eth_tx_by_index.is_empty() { + // Nothing to reconstruct: no ethereum transactions and no synthetic transaction (the + // latter is present iff the runtime appended a trailing receipt-gas entry). + if eth_tx_by_index.is_empty() && synthetic_gas_info.is_none() { return Ok(vec![]); } + // The synthetic transaction is appended after every ethereum transaction. + let synthetic_tx_index = eth_tx_by_index.keys().max().map_or(0, |max| max + 1); + let substrate_block_number = block.number(); let eth_block_number: U256 = substrate_block_number.into(); + let (_, synthetic_tx_hash) = self.synthetic_tx(eth_block_number); let block_events = block.events().await.inspect_err(|err| { log::debug!(target: LOG_TARGET, "Error fetching events for block #{substrate_block_number}: {err:?}"); })?; - let (reverted_extrinsics, mut logs_by_extrinsic) = extract_revive_events( + let (reverted_extrinsics, mut logs_by_extrinsic, outside_frame_logs) = extract_revive_events( &block_events, substrate_block_number, eth_block_number, eth_block_hash, |idx| eth_tx_by_index.get(&idx).map(|(_, hash, _)| *hash), + synthetic_tx_hash, + synthetic_tx_index, ); - eth_tx_by_index + let mut receipts: Vec<_> = eth_tx_by_index .into_iter() .map(|(transaction_index, (call, transaction_hash, receipt_gas_info))| { let reverted = reverted_extrinsics.contains(&transaction_index); @@ -413,21 +496,42 @@ impl ReceiptExtractor { log::warn!(target: LOG_TARGET, "Error extracting extrinsic: {err:?}"); }) }) - .collect() + .collect::, _>>()?; + + // Append the synthetic transaction receipt for the block's outside-of-frame logs. + if let Some(gas_info) = synthetic_gas_info { + if !outside_frame_logs.is_empty() { + receipts.push(self.build_synthetic_receipt( + eth_block_hash, + eth_block_number, + synthetic_tx_index, + gas_info, + outside_frame_logs, + )?); + } + } + + Ok(receipts) } /// Return the ETH extrinsics of the block grouped with reconstruction receipt info and - /// extrinsic index + /// extrinsic index, plus the optional trailing gas entry for the block's synthetic transaction. + /// + /// The runtime emits one `ReceiptGasInfo` per ethereum transaction, and — when a block has + /// outside-of-frame logs — one extra trailing entry for the synthetic transaction that carries + /// them. So `receipt_data.len()` is either `extrinsics.len()` (no synthetic) or + /// `extrinsics.len() + 1` (synthetic present, its gas entry is the last one). async fn get_block_extrinsics( &self, block: &SubstrateBlock, - ) -> Result, ClientError> { + ) -> Result<(Vec<(EthTransact, ReceiptGasInfoV1, usize)>, Option), ClientError> + { // Filter extrinsics from pallet_revive let extrinsics = block.extrinsics().await.inspect_err(|err| { log::debug!(target: LOG_TARGET, "Error fetching for #{:?} extrinsics: {err:?}", block.number()); })?; - let receipt_data = (self.fetch_receipt_data)(block.hash()).await.ok_or_else(|| { + let mut receipt_data = (self.fetch_receipt_data)(block.hash()).await.ok_or_else(|| { log::trace!(target: LOG_TARGET, "Receipt data not found for block #{} ({:?})", block.number(), block.hash()); @@ -442,6 +546,13 @@ impl ReceiptExtractor { }) .collect(); + // Split off the synthetic transaction's trailing gas entry, if present. + let synthetic_gas_info = if receipt_data.len() == extrinsics.len() + 1 { + receipt_data.pop() + } else { + None + }; + // Sanity check we received enough data from the pallet revive. if receipt_data.len() != extrinsics.len() { log::error!( @@ -452,10 +563,14 @@ impl ReceiptExtractor { ); Err(ClientError::ReceiptDataLengthMismatch) } else { - Ok(extrinsics - .into_iter() - .zip(receipt_data) - .map(|((call, ext_idx), rec)| (call, rec, ext_idx))) + Ok(( + extrinsics + .into_iter() + .zip(receipt_data) + .map(|((call, ext_idx), rec)| (call, rec, ext_idx)) + .collect(), + synthetic_gas_info, + )) } } @@ -466,37 +581,57 @@ impl ReceiptExtractor { block: &SubstrateBlock, transaction_index: usize, ) -> Result<(TransactionSigned, ReceiptInfo), ClientError> { - let (eth_call, receipt_gas_info, transaction_hash) = self - .get_block_extrinsics(block) - .await? - .find_map(|(call, receipt_gas_info, extrinsic_index)| { - (extrinsic_index == transaction_index).then(|| { - let hash = H256(keccak_256(&call.payload)); - (call, receipt_gas_info, hash) - }) + let (extrinsics, synthetic_gas_info) = self.get_block_extrinsics(block).await?; + let mut eth_tx_by_index: BTreeMap = extrinsics + .into_iter() + .map(|(call, receipt_gas_info, extrinsic_index)| { + let hash = H256(keccak_256(&call.payload)); + (extrinsic_index, (call, hash, receipt_gas_info)) }) - .ok_or_else(|| { - log::trace!(target: LOG_TARGET, - "extract_from_transaction: no EVM extrinsic at tx_index {transaction_index} \ - in block #{} ({:?})", block.number(), block.hash()); - ClientError::EthExtrinsicNotFound - })?; + .collect(); + + let synthetic_tx_index = eth_tx_by_index.keys().max().map_or(0, |max| max + 1); + let is_synthetic = + transaction_index == synthetic_tx_index && synthetic_gas_info.is_some(); + + if !eth_tx_by_index.contains_key(&transaction_index) && !is_synthetic { + log::trace!(target: LOG_TARGET, + "extract_from_transaction: no EVM extrinsic at tx_index {transaction_index} \ + in block #{} ({:?})", block.number(), block.hash()); + return Err(ClientError::EthExtrinsicNotFound); + } let substrate_block_number = block.number(); let eth_block_number: U256 = substrate_block_number.into(); let eth_block_hash = self.resolve_eth_block_hash(block.hash(), substrate_block_number as u64).await; + let (_, synthetic_tx_hash) = self.synthetic_tx(eth_block_number); let block_events = block.events().await.inspect_err(|err| { log::debug!(target: LOG_TARGET, "Error fetching events for block #{substrate_block_number}: {err:?}"); })?; - let (reverted_extrinsics, mut logs_by_extrinsic) = extract_revive_events( + let (reverted_extrinsics, mut logs_by_extrinsic, outside_frame_logs) = extract_revive_events( &block_events, substrate_block_number, eth_block_number, eth_block_hash, - |idx| (idx == transaction_index).then_some(transaction_hash), + |idx| eth_tx_by_index.get(&idx).map(|(_, hash, _)| *hash), + synthetic_tx_hash, + synthetic_tx_index, ); + if is_synthetic { + return self.build_synthetic_receipt( + eth_block_hash, + eth_block_number, + synthetic_tx_index, + synthetic_gas_info.expect("is_synthetic implies Some; qed"), + outside_frame_logs, + ); + } + + let (eth_call, transaction_hash, receipt_gas_info) = eth_tx_by_index + .remove(&transaction_index) + .expect("presence checked above; qed"); let reverted = reverted_extrinsics.contains(&transaction_index); let logs = logs_by_extrinsic.remove(&transaction_index).unwrap_or_default(); self.decode_transaction_and_build_receipt( @@ -757,15 +892,18 @@ mod tests { let substrate_block_number = 42u32; let eth_block_number = U256::from(substrate_block_number); - let (reverts, logs) = extract_revive_events( + let (reverts, logs, outside_frame) = extract_revive_events( &events, substrate_block_number, eth_block_number, eth_block_hash, |idx| (idx == 5).then_some(tx_hash), + H256::zero(), + 99, ); assert!(reverts.is_empty()); + assert!(outside_frame.is_empty()); assert_eq!(logs.len(), 1); let log = &logs[&5][0]; assert_eq!(log.address, contract); @@ -778,9 +916,10 @@ mod tests { } #[test] - fn extract_revive_events_skips_irrelevant_events() { - // Events outside `ApplyExtrinsic` and events for extrinsics the tx-hash closure - // doesn't resolve are both dropped. + fn extract_revive_events_buckets_non_eth_logs_as_outside_frame() { + // Events outside `ApplyExtrinsic` are dropped. A `ContractEmitted` on an extrinsic that is + // not an ethereum transaction becomes an outside-of-frame log (attributed to the synthetic + // transaction); a revert on such an extrinsic is ignored. let empty_contract_emitted = pallet_revive::Event::ContractEmitted { contract: H160::zero(), data: vec![], @@ -796,14 +935,19 @@ mod tests { .push_event(frame_system::Phase::ApplyExtrinsic(5), revert) .build(); - // The tx-hash closure returns `Some` only for extrinsic 7 (not present) - let (reverts, logs) = + let synthetic_hash = H256::from([0x99; 32]); + // The tx-hash closure returns `Some` only for extrinsic 7 (not present), so extrinsic 5 is + // treated as non-eth. + let (reverts, logs, outside_frame) = extract_revive_events(&events, 0, U256::zero(), H256::zero(), |idx| { (idx == 7).then_some(H256::zero()) - }); + }, synthetic_hash, 3); assert!(reverts.is_empty()); assert!(logs.is_empty()); + assert_eq!(outside_frame.len(), 1); + assert_eq!(outside_frame[0].transaction_hash, synthetic_hash); + assert_eq!(outside_frame[0].transaction_index, U256::from(3)); } #[test] @@ -828,14 +972,15 @@ mod tests { .push_event(frame_system::Phase::ApplyExtrinsic(2), emitted_by(H160::from([0xcc; 20]))) .build(); - let (reverts, logs) = + let (reverts, logs, outside_frame) = extract_revive_events(&events, 0, U256::zero(), H256::zero(), |idx| match idx { 0 => Some(tx0), 1 => Some(tx1), 2 => Some(tx2), _ => None, - }); + }, H256::zero(), 3); + assert!(outside_frame.is_empty()); assert_eq!(reverts, [1usize].into_iter().collect::>()); assert_eq!(logs[&0].len(), 2); assert_eq!(logs[&2].len(), 1); diff --git a/substrate/frame/revive/rpc/src/tests.rs b/substrate/frame/revive/rpc/src/tests.rs index 93b194034f4b..2a8c2f0591e2 100644 --- a/substrate/frame/revive/rpc/src/tests.rs +++ b/substrate/frame/revive/rpc/src/tests.rs @@ -366,6 +366,7 @@ async fn run_all_eth_rpc_tests_inner() -> anyhow::Result<()> { test_subscribe_new_heads, test_subscribe_new_heads_multiple_blocks, test_subscribe_logs, + test_outside_of_frame_log_served_via_synthetic_tx, test_subscribe_logs_with_address_filter, test_subscribe_logs_with_topic_filter, test_subscribe_logs_address_filter_excludes_non_matching, @@ -1250,6 +1251,102 @@ async fn test_subscribe_logs() -> anyhow::Result<()> { Ok(()) } +/// End-to-end: a substrate-native call (`Revive::call`, not `eth_transact`) that emits a contract +/// log produces an "outside-of-frame" log. The runtime aggregates it into the block's synthetic +/// transaction; eth-rpc must reconstruct that transaction and serve the log via `eth_getLogs` and +/// `eth_getTransactionReceipt`. +async fn test_outside_of_frame_log_served_via_synthetic_tx() -> anyhow::Result<()> { + let client = Arc::new(SharedResources::client().await); + let node_client = SharedResources::node_client().await; + let account = Account::default(); + + // Deploy an event-emitting contract via EVM. + let (bytes, _) = pallet_revive_fixtures::compile_module_with_type( + "SimpleReceiver", + pallet_revive_fixtures::FixtureType::Solc, + )?; + let nonce = client.get_transaction_count(account.address(), Default::default()).await?; + let deploy = TransactionBuilder::new(client.clone()).input(bytes.to_vec()).send().await?; + deploy.wait_for_receipt().await?; + let contract_address = create1(&account.address(), nonce.try_into().unwrap()); + + // Call it via a *substrate* extrinsic (outside any ethereum transaction). Sending value + // triggers the payable `Received` event, which becomes an outside-of-frame log. + let call = subxt::dynamic::tx( + "Revive", + "call", + vec![ + subxt::dynamic::Value::from_bytes(contract_address.as_bytes()), + subxt::dynamic::Value::u128(1_000_000_000_000), // value + subxt::dynamic::Value::named_composite(vec![ + ("ref_time".to_string(), subxt::dynamic::Value::u128(100_000_000_000)), + ("proof_size".to_string(), subxt::dynamic::Value::u128(5_000_000)), + ]), + subxt::dynamic::Value::u128(u128::MAX), // storage_deposit_limit + subxt::dynamic::Value::from_bytes(Vec::::new()), // data + ], + ); + + let alice = subxt_signer::sr25519::dev::alice(); + let params = subxt::config::polkadot::PolkadotExtrinsicParamsBuilder::new().build(); + let signed = node_client.tx().create_signed(&call, &alice, params).await?; + let mut progress = signed.submit_and_watch().await?; + let in_block = loop { + match progress.next().await { + Some(Ok(TxStatus::InBestBlock(b))) | Some(Ok(TxStatus::InFinalizedBlock(b))) => break b, + Some(Ok(_)) => {}, + Some(Err(e)) => return Err(anyhow!("Revive::call watch error: {e}")), + None => return Err(anyhow!("Revive::call: stream ended without inclusion")), + } + }; + in_block.wait_for_success().await?; + let block_number = node_client.blocks().at(in_block.block_hash()).await?.number(); + + // The eth block number equals the substrate block number. + let topic0 = H256(sp_io::hashing::keccak_256(b"Received(address,uint256)")); + let filter = Filter::new() + .from_block(BlockNumberOrTag::Number(block_number as u64)) + .to_block(BlockNumberOrTag::Number(block_number as u64)); + + // eth-rpc indexes substrate blocks asynchronously; poll until the block is synced and the log + // appears (tolerating transient "block not yet indexed" errors from `eth_getLogs`). + let mut found: Option = None; + for _ in 0..40 { + if let Ok(FilterResults::Logs(logs)) = client.get_logs(Some(filter.clone())).await { + if let Some(log) = logs + .into_iter() + .find(|log| log.address == contract_address && log.topics.first() == Some(&topic0)) + { + found = Some(log); + break; + } + } + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + let log = found.expect("outside-of-frame log should be served via eth_getLogs"); + + // The log must be attributed to the block's synthetic transaction — the hash eth-rpc + // reconstructs from the shared constructor, matching what the runtime committed. + let chain_id = client.chain_id().await?; + let synthetic_payload = + pallet_revive::evm::synthetic_log_transaction(U256::from(block_number), chain_id); + let synthetic_hash = H256(sp_io::hashing::keccak_256(&synthetic_payload)); + assert_eq!(log.transaction_hash, synthetic_hash, "log attributed to the synthetic transaction"); + + // The synthetic transaction's receipt serves the same log, with a zero sender. + let receipt = client + .get_transaction_receipt(synthetic_hash) + .await? + .expect("synthetic transaction receipt should be served"); + assert_eq!(receipt.from, Default::default(), "synthetic transaction sender is zero"); + assert!( + receipt.logs.iter().any(|log| log.address == contract_address), + "synthetic receipt should carry the contract log" + ); + + Ok(()) +} + /// Verify that subscribing to `logs` with an address filter only delivers logs /// emitted from the specified contract address. async fn test_subscribe_logs_with_address_filter() -> anyhow::Result<()> { diff --git a/substrate/frame/revive/src/benchmarking.rs b/substrate/frame/revive/src/benchmarking.rs index 197ceaf8d1a4..b665bfe7ffea 100644 --- a/substrate/frame/revive/src/benchmarking.rs +++ b/substrate/frame/revive/src/benchmarking.rs @@ -3507,7 +3507,7 @@ mod benchmarks { // Create e events with minimal data to isolate event count overhead for _ in 0..e { - block_storage::capture_ethereum_log(&instance.address, &vec![], &vec![]); + block_storage::capture_ethereum_log::(&instance.address, &vec![], &vec![]); } #[block] @@ -3519,8 +3519,9 @@ mod benchmarks { let _ = Pallet::::on_finalize(current_block); } - // Verify transaction count - assert_eq!(Pallet::::eth_block().transactions.len(), 1); + // One real transaction, plus the synthetic transaction that carries the `e` + // outside-of-frame logs (only present when `e > 0`). + assert_eq!(Pallet::::eth_block().transactions.len(), 1 + (e > 0) as usize); Ok(()) } @@ -3593,7 +3594,7 @@ mod benchmarks { (event_data, topics) }; - block_storage::capture_ethereum_log(&instance.address, &event_data, &topics); + block_storage::capture_ethereum_log::(&instance.address, &event_data, &topics); #[block] { @@ -3604,8 +3605,49 @@ mod benchmarks { let _ = Pallet::::on_finalize(current_block); } - // Verify transaction count - assert_eq!(Pallet::::eth_block().transactions.len(), 1); + // One real transaction, plus the synthetic transaction carrying the outside-of-frame log. + assert_eq!(Pallet::::eth_block().transactions.len(), 2); + + Ok(()) + } + + /// Benchmark the `on_finalize` drain of the outside-of-frame log buffer, scaling with the number + /// of buffered logs `n`. + /// + /// Each buffered log is drained with an `OutsideFrameLogs::take` (a distinct storage key) and + /// folded into the synthetic transaction's receipt (RLP + bloom). Unlike `on_finalize_per_event` + /// this isolates that path with no real transaction present, and — being `pov_mode = Measured` — + /// captures the per-log **proof size** the take incurs, which the reservation in `on_initialize` + /// (`MaxOutsideFrameLogs` × the marginal) depends on. + /// + /// Each log uses a representative ERC-20 `Transfer` payload: three 32-byte topics and a 32-byte + /// data word. + #[benchmark(pov_mode = Measured)] + fn on_finalize_per_outside_frame_log(n: Linear<0, 100>) -> Result<(), BenchmarkError> { + let (instance, _storage_deposit, _evm_value, _signer_key, current_block) = + setup_finalize_block_benchmark::()?; + + // Realistic order: `on_initialize` runs before the block's extrinsics buffer any logs. + let _ = Pallet::::on_initialize(current_block); + + // Representative ERC-20 `Transfer`: topics = [event sig, from, to], data = value word. + let topics = vec![H256::repeat_byte(0x11), H256::repeat_byte(0x22), H256::repeat_byte(0x33)]; + let data = vec![0x44u8; 32]; + + // Buffer n outside-of-frame logs. No ethereum context is active, so each is captured into + // `OutsideFrameLogs` rather than a transaction receipt. + for _ in 0..n { + block_storage::capture_ethereum_log::(&instance.address, &data, &topics); + } + + // Measure only the `on_finalize` drain of the buffered logs. + #[block] + { + let _ = Pallet::::on_finalize(current_block); + } + + // Only the synthetic transaction, and only when at least one log was buffered. + assert_eq!(Pallet::::eth_block().transactions.len(), (n > 0) as usize); Ok(()) } diff --git a/substrate/frame/revive/src/evm/api/transaction.rs b/substrate/frame/revive/src/evm/api/transaction.rs index ca8fb44c77d9..91d4ebda0686 100644 --- a/substrate/frame/revive/src/evm/api/transaction.rs +++ b/substrate/frame/revive/src/evm/api/transaction.rs @@ -389,6 +389,23 @@ impl Default for TransactionUnsigned { } } +/// Build the deterministic payload of the synthetic transaction that carries a block's +/// "outside-of-frame" logs: logs emitted with no enclosing ethereum transaction (e.g. pallet-assets +/// balance-change mirrors on non-`eth_transact` paths). +/// +/// Shared by the runtime — which emits this transaction into the block so its logs enter the +/// `logs_bloom`, `receipts_root` and transaction trie — and by the eth-rpc layer, which rebuilds the +/// identical bytes to recover its hash. It is an unsigned legacy transaction distinguished only by +/// `nonce = block_number` (so the hash is unique per block) and is not an executable transaction. +pub fn synthetic_log_transaction(block_number: U256, chain_id: U256) -> Vec { + TransactionUnsigned::from(TransactionLegacyUnsigned { + nonce: block_number, + chain_id: Some(chain_id), + ..Default::default() + }) + .dummy_signed_payload() +} + impl From for TransactionUnsigned { fn from(tx: TransactionSigned) -> Self { use TransactionSigned::*; diff --git a/substrate/frame/revive/src/evm/block_storage.rs b/substrate/frame/revive/src/evm/block_storage.rs index 270dd95f4191..7dc803d05ec7 100644 --- a/substrate/frame/revive/src/evm/block_storage.rs +++ b/substrate/frame/revive/src/evm/block_storage.rs @@ -17,7 +17,9 @@ use crate::{ AccountIdOf, BalanceOf, BalanceWithDust, BlockHash, BlockNumberFor, Config, ContractResult, Error, EthBlockBuilderIR, EthereumBlock, Event, ExecReturnValue, H160, H256, LOG_TARGET, - Pallet, ReceiptGasInfo, ReceiptInfoData, StorageDeposit, Weight, dispatch_result, + OutsideFrameLogCount, OutsideFrameLogs, Pallet, ReceiptGasInfo, ReceiptInfoData, StorageDeposit, + Weight, + dispatch_result, evm::{ block_hash::{AccumulateReceipt, EthereumBlockBuilder, LogsBloom}, burn_with_dust, @@ -33,6 +35,7 @@ use frame_support::{ dispatch::DispatchInfo, pallet_prelude::{DispatchError, DispatchResultWithPostInfo}, storage::with_transaction, + traits::Get, }; use sp_core::U256; use sp_runtime::{Saturating, TransactionOutcome}; @@ -120,11 +123,32 @@ impl EthereumCallResult { /// Capture the Ethereum log for the current transaction. /// -/// This method does nothing if called from outside of the ethereum context. -pub fn capture_ethereum_log(contract: &H160, data: &[u8], topics: &[H256]) { - receipt::with(|receipt| { +/// Inside an ethereum transaction the log is added to that transaction's receipt. Outside of one +/// (e.g. a log mirrored from a plain extrinsic or XCM balance change) there is no receipt to +/// attach it to, so it is accumulated into the block-level buffer, flushed in `on_finalize` as a +/// synthetic transaction so the log still enters the block's bloom, receipts_root and tx trie. +pub fn capture_ethereum_log(contract: &H160, data: &[u8], topics: &[H256]) { + let captured = receipt::with(|receipt| { receipt.add_log(contract, data, topics); }); + + if captured.is_none() { + let index = OutsideFrameLogCount::::get(); + // Cap the per-block buffer so the `on_finalize` drain (reserved in `on_initialize` as + // `MaxOutsideFrameLogs * on_finalize_block_per_outside_frame_log`) can never exceed its + // declared weight. The per-emit weight charged on each firing path is expected to exhaust + // block weight well before this backstop is hit; a log dropped here has no `Transfer` entry + // despite its balance event, so reaching the cap is a degraded, warned condition. + if index >= ::MaxOutsideFrameLogs::get() { + log::warn!( + target: LOG_TARGET, + "outside-of-frame log buffer full ({index} logs); dropping log for {contract:?}", + ); + return; + } + OutsideFrameLogs::::insert(index, (*contract, topics.to_vec(), data.to_vec())); + OutsideFrameLogCount::::put(index.saturating_add(1)); + } } /// Get the receipt details of the current transaction. @@ -203,13 +227,45 @@ pub fn on_initialize() { EthereumBlock::::kill(); } +/// The synthetic transaction that carries the block's outside-of-frame logs. +/// +/// An unsigned legacy transaction whose only distinguishing field is the block number as `nonce`, +/// so its hash is unique per block and deterministically reproducible offchain (the eth-rpc +/// serving layer rebuilds the identical bytes from the block number and chain id). It is not a +/// real, executable transaction — it exists only to give the aggregated logs a receipt so they +/// enter the block's bloom, `receipts_root` and transaction trie. +fn synthetic_transaction(block_number: BlockNumberFor) -> Vec { + crate::evm::synthetic_log_transaction(block_number.into(), T::ChainId::get().into()) +} + /// Build the ethereum block and store it into the pallet storage. pub fn on_finalize_build_eth_block(block_number: BlockNumberFor) { let block_builder_ir = EthBlockBuilderIR::::get(); EthBlockBuilderIR::::kill(); - let (block, receipt_data) = - EthereumBlockBuilder::::from_ir(block_builder_ir).build_block(block_number); + let mut block_builder = EthereumBlockBuilder::::from_ir(block_builder_ir); + + // Flush logs emitted outside any ethereum transaction as a single synthetic transaction, so + // they enter the block bloom / receipts_root / transaction trie. Must run after all real + // transactions have been processed, since the trie builders are order-sensitive. + let outside_frame_log_count = OutsideFrameLogCount::::take(); + if outside_frame_log_count > 0 { + let mut receipt = AccumulateReceipt::new(); + for index in 0..outside_frame_log_count { + if let Some((contract, topics, data)) = OutsideFrameLogs::::take(index) { + receipt.add_log(&contract, &data, &topics); + } + } + block_builder.process_transaction( + synthetic_transaction::(block_number), + true, + ReceiptGasInfo { gas_used: U256::zero(), effective_gas_price: U256::zero() }, + receipt.encoding, + receipt.bloom, + ); + } + + let (block, receipt_data) = block_builder.build_block(block_number); // Put the block hash into storage. BlockHash::::insert(block_number, block.hash); diff --git a/substrate/frame/revive/src/exec.rs b/substrate/frame/revive/src/exec.rs index ae913b8a88a8..aed2debfc1a5 100644 --- a/substrate/frame/revive/src/exec.rs +++ b/substrate/frame/revive/src/exec.rs @@ -2411,8 +2411,7 @@ where tracer.log_event(contract, &topics, &data, log_index); }); - // Capture the log only if it is generated by an Ethereum transaction. - crate::evm::block_storage::capture_ethereum_log(&contract, &data, &topics); + crate::evm::block_storage::capture_ethereum_log::(&contract, &data, &topics); Contracts::::deposit_event(Event::ContractEmitted { contract, data, topics }); } diff --git a/substrate/frame/revive/src/lib.rs b/substrate/frame/revive/src/lib.rs index d07077cb7132..0551086eea4e 100644 --- a/substrate/frame/revive/src/lib.rs +++ b/substrate/frame/revive/src/lib.rs @@ -397,6 +397,18 @@ pub mod pallet { #[pallet::constant] #[pallet::no_default_bounds] type GasScale: Get; + + /// Maximum number of logs that may be buffered outside any ethereum transaction within a + /// single block (see [`OutsideFrameLogs`]). + /// + /// These are logs mirrored from substrate-native activity (e.g. plain pallet-assets + /// transfers, XCM) that have no ethereum transaction to attach a receipt to; they are + /// drained in `on_finalize` into one synthetic transaction. This bound caps the worst-case + /// drain cost so it can be reserved up-front in `on_initialize`. Buffering beyond the cap + /// drops the log (see `capture_ethereum_log`); the per-emit weight charged on each firing + /// path is expected to exhaust block weight before the cap is reached. + #[pallet::constant] + type MaxOutsideFrameLogs: Get; } /// Container for different types that implement [`DefaultConfig`]` of this pallet. @@ -483,6 +495,7 @@ pub mod pallet { type AutoMap = ConstBool; type GasScale = GasScale; type OnBurn = (); + type MaxOutsideFrameLogs = ConstU32<1024>; } } @@ -805,6 +818,24 @@ pub mod pallet { pub(crate) type EthBlockBuilderFirstValues = StorageValue<_, Option<(Vec, Vec)>, ValueQuery>; + /// Logs emitted during the block outside of any ethereum transaction (e.g. by pallet-assets + /// balance-change callbacks on non-`eth_transact` paths), keyed by emission order. + /// + /// Each log is written as its own entry so buffering a log is a single bounded write rather + /// than a read-modify-write of a growing accumulator. Drained in emission order in + /// `on_finalize` and flushed as a single synthetic transaction receipt, so the logs enter the + /// block's `logs_bloom`, `receipts_root` and transaction trie. + /// + /// NOTE: unbounded; accumulated across the block and consumed in `on_finalize`. + #[pallet::storage] + #[pallet::unbounded] + pub(crate) type OutsideFrameLogs = + StorageMap<_, Twox64Concat, u32, (H160, Vec, Vec), OptionQuery>; + + /// Number of logs buffered in [`OutsideFrameLogs`] this block; also the next emission index. + #[pallet::storage] + pub(crate) type OutsideFrameLogCount = StorageValue<_, u32, ValueQuery>; + /// Debugging settings that can be configured when DebugEnabled config is true. #[pallet::storage] pub(crate) type DebugSettingsOf = StorageValue<_, DebugSettings, ValueQuery>; @@ -969,8 +1000,14 @@ pub mod pallet { // Warm up the pallet account. System::::account_exists(&Pallet::::account_id()); - // Account for the fixed part of the costs incurred in `on_finalize`. - ::WeightInfo::on_finalize_block_fixed() + // Account for the fixed part of the costs incurred in `on_finalize`, plus the worst-case + // drain of the outside-of-frame log buffer (bounded by `MaxOutsideFrameLogs`). The + // buffer is filled during the block's extrinsics, after `on_initialize` runs, so the + // count is unknown here and the cap is reserved conservatively. + ::WeightInfo::on_finalize_block_fixed().saturating_add( + ::WeightInfo::on_finalize_block_per_outside_frame_log() + .saturating_mul(::MaxOutsideFrameLogs::get().into()), + ) } fn on_finalize(block_number: BlockNumberFor) { @@ -2642,16 +2679,17 @@ impl Pallet { Ok(maybe_value) } - /// Emit an EVM log attributed to `contract`: traced, captured into the ethereum receipt - /// (inside an ethereum transaction), and deposited as [`Event::ContractEmitted`]. The - /// single emission path — used by contract execution and log-mirroring runtime components. + /// Emit an EVM log attributed to `contract`: traced, captured into the current ethereum + /// receipt (or the block's synthetic receipt when outside an ethereum transaction), and + /// deposited as [`Event::ContractEmitted`]. The single emission path — used by contract + /// execution and log-mirroring runtime components. pub fn emit_contract_log_outside_frame(contract: H160, topics: Vec, data: Vec) { if_tracing(|tracer| { let log_index = frame_system::Pallet::::event_count(); tracer.log_event_outside_frame(contract, &topics, &data, log_index); }); - evm::block_storage::capture_ethereum_log(&contract, &data, &topics); + evm::block_storage::capture_ethereum_log::(&contract, &data, &topics); Self::deposit_event(Event::ContractEmitted { contract, data, topics }); } diff --git a/substrate/frame/revive/src/tests.rs b/substrate/frame/revive/src/tests.rs index a328c399b7c4..cef72db4c303 100644 --- a/substrate/frame/revive/src/tests.rs +++ b/substrate/frame/revive/src/tests.rs @@ -760,6 +760,58 @@ fn tracing_a_log_emitted_outside_a_call_frame_does_not_panic() { }); } +#[test] +fn logs_emitted_outside_a_call_frame_land_in_the_block_bloom() { + use crate::{ + EthereumBlock, OutsideFrameLogCount, + evm::{HashesOrTransactionInfos, block_hash::LogsBloom, block_storage}, + }; + use sp_core::H256; + use sp_crypto_hashing::keccak_256; + + ExtBuilder::default().build().execute_with(|| { + let contract = H160::from_low_u64_be(0x1234); + let topic = H256::repeat_byte(0x11); + + // No ethereum transaction this block: a runtime component mirrors a balance change as a + // log, outside any ethereum call frame. + Pallet::::emit_contract_log_outside_frame(contract, vec![topic], vec![1, 2, 3]); + + // The log is parked in the block-level buffer until finalization. + assert_eq!(OutsideFrameLogCount::::get(), 1); + + block_storage::on_finalize_build_eth_block::(1); + + // The buffer is consumed by the synthetic transaction. + assert_eq!(OutsideFrameLogCount::::get(), 0); + + let block = EthereumBlock::::get(); + + // A single synthetic transaction now carries the log. + let hashes = match block.transactions { + HashesOrTransactionInfos::Hashes(hashes) => hashes, + _ => panic!("expected transaction hashes"), + }; + assert_eq!(hashes.len(), 1); + + // The committed block bloom equals the log's bloom — i.e. the outside-of-frame log made it + // into the block's `logs_bloom`, which is the whole point. + let mut expected = LogsBloom::new(); + expected.accrue_log(&contract, &[topic]); + assert_ne!(expected.bloom, [0u8; 256]); + assert_eq!(block.logs_bloom.0, expected.bloom); + + // End-to-end contract with eth-rpc: the synthetic transaction hash the runtime committed + // must equal the hash eth-rpc reconstructs. eth-rpc rebuilds the bytes with the shared + // `synthetic_log_transaction(block_number, chain_id)`, so recompute it here and assert the + // committed `block.transactions` entry matches. Guards the emit path (nonce = block number, + // chain id, trie ordering) against the reconstructor. + let chain_id = ::ChainId::get(); + let reconstructed = crate::evm::synthetic_log_transaction(U256::from(1), U256::from(chain_id)); + assert_eq!(hashes[0], H256(keccak_256(&reconstructed))); + }); +} + #[test] fn tracing_a_log_emitted_inside_a_call_frame_attaches_to_it() { use crate::{evm::CallTracer, tracing::Tracing}; diff --git a/substrate/frame/revive/src/weightinfo_extension.rs b/substrate/frame/revive/src/weightinfo_extension.rs index 9768dcd1fe18..84ab9c296545 100644 --- a/substrate/frame/revive/src/weightinfo_extension.rs +++ b/substrate/frame/revive/src/weightinfo_extension.rs @@ -54,6 +54,15 @@ pub trait OnFinalizeBlockParts { /// # Parameters /// - `data_len`: Total bytes of event data (includes topics and data field) fn on_finalize_block_per_event(data_len: u32) -> Weight; + + /// Returns the per-log weight cost for draining one buffered outside-of-frame log in + /// `on_finalize` (see `OutsideFrameLogs`). + /// + /// Each such log is a `Transfer` log mirrored from a substrate-native balance change. This + /// marginal cost, multiplied by [`Config::MaxOutsideFrameLogs`], is reserved up-front in + /// `on_initialize` so the synthetic transaction's drain never exceeds the block's declared + /// weight. + fn on_finalize_block_per_outside_frame_log() -> Weight; } /// Implementation of `OnFinalizeBlockParts` that derives high-level weights from `WeightInfo` @@ -121,4 +130,12 @@ impl OnFinalizeBlockParts for W { per_event_cost.saturating_add(data_cost) } + + fn on_finalize_block_per_outside_frame_log() -> Weight { + // Marginal cost of draining one buffered outside-of-frame log. The dedicated benchmark is + // `pov_mode = Measured`, so this marginal carries the per-log proof size that + // `on_finalize_per_event` (constant proof estimate) does not. + W::on_finalize_per_outside_frame_log(1) + .saturating_sub(W::on_finalize_per_outside_frame_log(0)) + } } diff --git a/substrate/frame/revive/src/weights.rs b/substrate/frame/revive/src/weights.rs index a38987150e69..9b9308bba7a7 100644 --- a/substrate/frame/revive/src/weights.rs +++ b/substrate/frame/revive/src/weights.rs @@ -188,6 +188,7 @@ pub trait WeightInfo { fn on_finalize_per_transaction_data(d: u32, ) -> Weight; fn on_finalize_per_event(e: u32, ) -> Weight; fn on_finalize_per_event_data(d: u32, ) -> Weight; + fn on_finalize_per_outside_frame_log(n: u32, ) -> Weight; } /// Weights for `pallet_revive` using the Substrate node and recommended hardware. @@ -1707,6 +1708,17 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(5_u64)) } + /// Placeholder — regenerate with `/cmd bench`. The per-`n` proof-size term is the point of this + /// entry: it captures the PoV of draining one buffered outside-of-frame log (`OutsideFrameLogs` + /// read/write), which `on_finalize_per_event` does not model. + fn on_finalize_per_outside_frame_log(n: u32, ) -> Weight { + Weight::from_parts(45_000_000, 5000) + .saturating_add(Weight::from_parts(50_000, 300).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(n.into()))) + } } // For backwards compatibility and tests. @@ -3225,4 +3237,15 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(5_u64)) } + /// Placeholder — regenerate with `/cmd bench`. The per-`n` proof-size term is the point of this + /// entry: it captures the PoV of draining one buffered outside-of-frame log (`OutsideFrameLogs` + /// read/write), which `on_finalize_per_event` does not model. + fn on_finalize_per_outside_frame_log(n: u32, ) -> Weight { + Weight::from_parts(45_000_000, 5000) + .saturating_add(Weight::from_parts(50_000, 300).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(5_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(n.into()))) + } }