Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 202 additions & 57 deletions substrate/frame/revive/rpc/src/receipt_extractor.rs

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions substrate/frame/revive/rpc/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<u8>::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<Log> = 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<()> {
Expand Down
13 changes: 7 additions & 6 deletions substrate/frame/revive/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<T>(&instance.address, &vec![], &vec![]);
}

#[block]
Expand All @@ -3519,8 +3519,9 @@ mod benchmarks {
let _ = Pallet::<T>::on_finalize(current_block);
}

// Verify transaction count
assert_eq!(Pallet::<T>::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::<T>::eth_block().transactions.len(), 1 + (e > 0) as usize);

Ok(())
}
Expand Down Expand Up @@ -3593,7 +3594,7 @@ mod benchmarks {
(event_data, topics)
};

block_storage::capture_ethereum_log(&instance.address, &event_data, &topics);
block_storage::capture_ethereum_log::<T>(&instance.address, &event_data, &topics);

#[block]
{
Expand All @@ -3604,8 +3605,8 @@ mod benchmarks {
let _ = Pallet::<T>::on_finalize(current_block);
}

// Verify transaction count
assert_eq!(Pallet::<T>::eth_block().transactions.len(), 1);
// One real transaction, plus the synthetic transaction carrying the outside-of-frame log.
assert_eq!(Pallet::<T>::eth_block().transactions.len(), 2);

Ok(())
}
Expand Down
17 changes: 17 additions & 0 deletions substrate/frame/revive/src/evm/api/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
TransactionUnsigned::from(TransactionLegacyUnsigned {
nonce: block_number,
chain_id: Some(chain_id),
..Default::default()
})
.dummy_signed_payload()
}

impl From<TransactionSigned> for TransactionUnsigned {
fn from(tx: TransactionSigned) -> Self {
use TransactionSigned::*;
Expand Down
56 changes: 50 additions & 6 deletions substrate/frame/revive/src/evm/block_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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};
Expand Down Expand Up @@ -120,11 +123,20 @@ 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<T: Config>(contract: &H160, data: &[u8], topics: &[H256]) {
let captured = receipt::with(|receipt| {
receipt.add_log(contract, data, topics);
});

if captured.is_none() {
let index = OutsideFrameLogCount::<T>::get();
OutsideFrameLogs::<T>::insert(index, (*contract, topics.to_vec(), data.to_vec()));
OutsideFrameLogCount::<T>::put(index.saturating_add(1));
}
}

/// Get the receipt details of the current transaction.
Expand Down Expand Up @@ -203,13 +215,45 @@ pub fn on_initialize<T: Config>() {
EthereumBlock::<T>::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<T: Config>(block_number: BlockNumberFor<T>) -> Vec<u8> {
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<T: Config>(block_number: BlockNumberFor<T>) {
let block_builder_ir = EthBlockBuilderIR::<T>::get();
EthBlockBuilderIR::<T>::kill();

let (block, receipt_data) =
EthereumBlockBuilder::<T>::from_ir(block_builder_ir).build_block(block_number);
let mut block_builder = EthereumBlockBuilder::<T>::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::<T>::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::<T>::take(index) {
receipt.add_log(&contract, &data, &topics);
}
}
block_builder.process_transaction(
synthetic_transaction::<T>(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::<T>::insert(block_number, block.hash);
Expand Down
3 changes: 1 addition & 2 deletions substrate/frame/revive/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<T>(&contract, &data, &topics);

Contracts::<Self::T>::deposit_event(Event::ContractEmitted { contract, data, topics });
}
Expand Down
27 changes: 23 additions & 4 deletions substrate/frame/revive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,24 @@ pub mod pallet {
pub(crate) type EthBlockBuilderFirstValues<T: Config> =
StorageValue<_, Option<(Vec<u8>, Vec<u8>)>, 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<T: Config> =
StorageMap<_, Twox64Concat, u32, (H160, Vec<H256>, Vec<u8>), OptionQuery>;

/// Number of logs buffered in [`OutsideFrameLogs`] this block; also the next emission index.
#[pallet::storage]
pub(crate) type OutsideFrameLogCount<T: Config> = StorageValue<_, u32, ValueQuery>;

/// Debugging settings that can be configured when DebugEnabled config is true.
#[pallet::storage]
pub(crate) type DebugSettingsOf<T: Config> = StorageValue<_, DebugSettings, ValueQuery>;
Expand Down Expand Up @@ -2642,16 +2660,17 @@ impl<T: Config> Pallet<T> {
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<H256>, data: Vec<u8>) {
if_tracing(|tracer| {
let log_index = frame_system::Pallet::<T>::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::<T>(&contract, &data, &topics);

Self::deposit_event(Event::ContractEmitted { contract, data, topics });
}
Expand Down
52 changes: 52 additions & 0 deletions substrate/frame/revive/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Test>::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::<Test>::get(), 1);

block_storage::on_finalize_build_eth_block::<Test>(1);

// The buffer is consumed by the synthetic transaction.
assert_eq!(OutsideFrameLogCount::<Test>::get(), 0);

let block = EthereumBlock::<Test>::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 = <Test as crate::Config>::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};
Expand Down
Loading