diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ec02ebcb..63a56d387f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Description of the upcoming release here. ### Added +- [#1787](https://github.com/FuelLabs/fuel-core/pull/1787): Handle processing of relayed (forced) transactions - [#1786](https://github.com/FuelLabs/fuel-core/pull/1786): Regenesis now includes off-chain tables. - [#1716](https://github.com/FuelLabs/fuel-core/pull/1716): Added support of WASM state transition along with upgradable execution that works with native(std) and WASM(non-std) executors. The `fuel-core` now requires a `wasm32-unknown-unknown` target to build. - [#1770](https://github.com/FuelLabs/fuel-core/pull/1770): Add the new L1 event type for forced transactions. diff --git a/crates/client/assets/schema.sdl b/crates/client/assets/schema.sdl index 3c848ae8a0..23f8a066cc 100644 --- a/crates/client/assets/schema.sdl +++ b/crates/client/assets/schema.sdl @@ -850,6 +850,7 @@ type Query { messages(owner: Address, first: Int, after: String, last: Int, before: String): MessageConnection! messageProof(transactionId: TransactionId!, nonce: Nonce!, commitBlockId: BlockId, commitBlockHeight: U32): MessageProof messageStatus(nonce: Nonce!): MessageStatus! + relayedTransactionStatus(id: RelayedTransactionId!): RelayedTransactionStatus } type Receipt { @@ -902,6 +903,15 @@ enum ReceiptType { BURN } +type RelayedTransactionFailed { + blockHeight: U32! + failure: String! +} + +scalar RelayedTransactionId + +union RelayedTransactionStatus = RelayedTransactionFailed + enum ReturnType { RETURN RETURN_DATA diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index ccdd4cadb7..2ff901afce 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -8,6 +8,7 @@ use crate::client::{ contract::ContractBalanceQueryArgs, gas_price::EstimateGasPrice, message::MessageStatusArgs, + relayed_tx::RelayedTransactionStatusArgs, tx::DryRunArg, Tai64Timestamp, TransactionId, @@ -22,6 +23,7 @@ use crate::client::{ ContractId, UtxoId, }, + RelayedTransactionStatus, }, }; use anyhow::Context; @@ -41,6 +43,7 @@ use fuel_core_types::{ Word, }, fuel_tx::{ + Bytes32, Receipt, Transaction, TxId, @@ -968,6 +971,24 @@ impl FuelClient { Ok(proof) } + + pub async fn relayed_transaction_status( + &self, + id: &Bytes32, + ) -> io::Result> { + let query = schema::relayed_tx::RelayedTransactionStatusQuery::build( + RelayedTransactionStatusArgs { + id: id.to_owned().into(), + }, + ); + let status = self + .query(query) + .await? + .relayed_transaction_status + .map(|status| status.try_into()) + .transpose()?; + Ok(status) + } } #[cfg(any(test, feature = "test-helpers"))] diff --git a/crates/client/src/client/schema.rs b/crates/client/src/client/schema.rs index 36f24e9444..cc470c2878 100644 --- a/crates/client/src/client/schema.rs +++ b/crates/client/src/client/schema.rs @@ -38,6 +38,8 @@ pub mod gas_price; pub mod primitives; pub mod tx; +pub mod relayed_tx; + #[derive(cynic::QueryFragment, Clone, Debug)] #[cynic(schema_path = "./assets/schema.sdl", graphql_type = "Query")] pub struct Health { diff --git a/crates/client/src/client/schema/primitives.rs b/crates/client/src/client/schema/primitives.rs index db41e3ff0b..c9a6911349 100644 --- a/crates/client/src/client/schema/primitives.rs +++ b/crates/client/src/client/schema/primitives.rs @@ -112,6 +112,7 @@ fuel_type_scalar!(AssetId, AssetId); fuel_type_scalar!(ContractId, ContractId); fuel_type_scalar!(Salt, Salt); fuel_type_scalar!(TransactionId, Bytes32); +fuel_type_scalar!(RelayedTransactionId, Bytes32); fuel_type_scalar!(Signature, Bytes64); fuel_type_scalar!(Nonce, Nonce); diff --git a/crates/client/src/client/schema/relayed_tx.rs b/crates/client/src/client/schema/relayed_tx.rs new file mode 100644 index 0000000000..44c6e130aa --- /dev/null +++ b/crates/client/src/client/schema/relayed_tx.rs @@ -0,0 +1,39 @@ +use crate::client::schema::{ + schema, + RelayedTransactionId, + U32, +}; + +#[derive(cynic::QueryFragment, Clone, Debug)] +#[cynic( + schema_path = "./assets/schema.sdl", + graphql_type = "Query", + variables = "RelayedTransactionStatusArgs" +)] +pub struct RelayedTransactionStatusQuery { + #[arguments(id: $id)] + pub relayed_transaction_status: Option, +} + +#[derive(cynic::QueryVariables, Debug)] +pub struct RelayedTransactionStatusArgs { + /// Transaction id that contains the output message. + pub id: RelayedTransactionId, +} + +#[allow(clippy::enum_variant_names)] +#[derive(cynic::InlineFragments, Clone, Debug)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub enum RelayedTransactionStatus { + /// Transaction was included in a block, but the execution was reverted + Failed(RelayedTransactionFailed), + #[cynic(fallback)] + Unknown, +} + +#[derive(cynic::QueryFragment, Clone, Debug, PartialEq, Eq)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub struct RelayedTransactionFailed { + pub block_height: U32, + pub failure: String, +} diff --git a/crates/client/src/client/types.rs b/crates/client/src/client/types.rs index f0db1e76aa..6b7af6e2bc 100644 --- a/crates/client/src/client/types.rs +++ b/crates/client/src/client/types.rs @@ -37,6 +37,7 @@ pub use message::{ pub use node_info::NodeInfo; use crate::client::schema::{ + relayed_tx::RelayedTransactionStatus as SchemaRelayedTransactionStatus, tx::{ OpaqueTransaction, TransactionStatus as SchemaTxStatus, @@ -169,3 +170,31 @@ impl TryFrom for TransactionResponse { }) } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayedTransactionStatus { + Failed { + block_height: BlockHeight, + failure: String, + }, +} + +impl TryFrom for RelayedTransactionStatus { + type Error = ConversionError; + + fn try_from(status: SchemaRelayedTransactionStatus) -> Result { + Ok(match status { + SchemaRelayedTransactionStatus::Failed(s) => { + RelayedTransactionStatus::Failed { + block_height: s.block_height.into(), + failure: s.failure, + } + } + SchemaRelayedTransactionStatus::Unknown => { + return Err(Self::Error::UnknownVariant( + "SchemaRelayedTransactionStatus", + )); + } + }) + } +} diff --git a/crates/fuel-core/src/database.rs b/crates/fuel-core/src/database.rs index 7f6c3c823d..707fd00222 100644 --- a/crates/fuel-core/src/database.rs +++ b/crates/fuel-core/src/database.rs @@ -593,7 +593,6 @@ mod tests { .insert(&prev_height, &CompressedBlock::default()); // Then - assert!(result.is_err()); assert_eq!( result.unwrap_err().to_string(), StorageError::from(DatabaseError::HeightsAreNotLinked { diff --git a/crates/fuel-core/src/executor.rs b/crates/fuel-core/src/executor.rs index c10836495b..d8c60da6c8 100644 --- a/crates/fuel-core/src/executor.rs +++ b/crates/fuel-core/src/executor.rs @@ -284,19 +284,7 @@ mod tests { da_block_height: DaBlockHeight, num_txs: usize, ) -> Block { - let transactions = (1..num_txs + 1) - .map(|i| { - TxBuilder::new(2322u64) - .script_gas_limit(10) - .coin_input(AssetId::default(), (i as Word) * 100) - .coin_output(AssetId::default(), (i as Word) * 50) - .change_output(AssetId::default()) - .build() - .transaction() - .clone() - .into() - }) - .collect_vec(); + let transactions = (1..num_txs + 1).map(script_tx_for_amount).collect_vec(); let mut block = Block::default(); block.header_mut().set_block_height(block_height); @@ -305,6 +293,19 @@ mod tests { block } + fn script_tx_for_amount(amount: usize) -> Transaction { + let asset = AssetId::BASE; + TxBuilder::new(2322u64) + .script_gas_limit(10) + .coin_input(asset, (amount as Word) * 100) + .coin_output(asset, (amount as Word) * 50) + .change_output(asset) + .build() + .transaction() + .to_owned() + .into() + } + pub(crate) fn create_contract( contract_code: Vec, rng: &mut R, @@ -761,7 +762,7 @@ mod tests { // register `0x13`(1 - true, 0 - false). op::meq(0x13, 0x10, 0x12, 0x11), // Return the result of the comparison as a receipt. - op::ret(0x13) + op::ret(0x13), ], expected_in_tx_coinbase.to_vec() /* pass expected address as script data */) .coin_input(AssetId::BASE, 1000) .variable_output(Default::default()) @@ -800,19 +801,19 @@ mod tests { assert!(compare_coinbase_addresses( ContractId::from([1u8; 32]), - ContractId::from([1u8; 32]) + ContractId::from([1u8; 32]), )); assert!(!compare_coinbase_addresses( ContractId::from([9u8; 32]), - ContractId::from([1u8; 32]) + ContractId::from([1u8; 32]), )); assert!(!compare_coinbase_addresses( ContractId::from([1u8; 32]), - ContractId::from([9u8; 32]) + ContractId::from([9u8; 32]), )); assert!(compare_coinbase_addresses( ContractId::from([9u8; 32]), - ContractId::from([9u8; 32]) + ContractId::from([9u8; 32]), )); } @@ -2837,6 +2838,11 @@ mod tests { use fuel_core_types::{ entities::RelayedTransaction, fuel_merkle::binary::root_calculator::MerkleRootCalculator, + fuel_tx::{ + output, + Chargeable, + }, + services::executor::ForcedTransactionFailure, }; fn database_with_genesis_block(da_block_height: u64) -> Database { @@ -2901,7 +2907,7 @@ mod tests { block_height: 1, block_da_height: 10, genesis_da_height: Some(0), - } => matches Ok(()) ; "block producer takes all 10 messages from the relayer" + } => matches Ok(()); "block producer takes all 10 messages from the relayer" )] #[test_case::test_case( Input { @@ -2909,7 +2915,7 @@ mod tests { block_height: 1, block_da_height: 5, genesis_da_height: Some(0), - } => matches Ok(()) ; "block producer takes first 5 messages from the relayer" + } => matches Ok(()); "block producer takes first 5 messages from the relayer" )] #[test_case::test_case( Input { @@ -2917,7 +2923,7 @@ mod tests { block_height: 1, block_da_height: 10, genesis_da_height: Some(5), - } => matches Ok(()) ; "block producer takes last 5 messages from the relayer" + } => matches Ok(()); "block producer takes last 5 messages from the relayer" )] #[test_case::test_case( Input { @@ -2925,7 +2931,7 @@ mod tests { block_height: 1, block_da_height: 10, genesis_da_height: Some(u64::MAX), - } => matches Err(ExecutorError::DaHeightExceededItsLimit) ; "block producer fails when previous block exceeds `u64::MAX`" + } => matches Err(ExecutorError::DaHeightExceededItsLimit); "block producer fails when previous block exceeds `u64::MAX`" )] #[test_case::test_case( Input { @@ -2933,7 +2939,7 @@ mod tests { block_height: 1, block_da_height: 10, genesis_da_height: None, - } => matches Err(ExecutorError::PreviousBlockIsNotFound) ; "block producer fails when previous block doesn't exist" + } => matches Err(ExecutorError::PreviousBlockIsNotFound); "block producer fails when previous block doesn't exist" )] #[test_case::test_case( Input { @@ -2941,7 +2947,7 @@ mod tests { block_height: 0, block_da_height: 10, genesis_da_height: Some(0), - } => matches Err(ExecutorError::ExecutingGenesisBlock) ; "block producer fails when block height is zero" + } => matches Err(ExecutorError::ExecutingGenesisBlock); "block producer fails when block height is zero" )] fn block_producer_takes_messages_from_the_relayer( input: Input, @@ -3038,6 +3044,522 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn execute_without_commit__relayed_tx_included_in_block() { + let genesis_da_height = 3u64; + let block_height = 1u32; + let da_height = 10u64; + let arb_large_max_gas = 10_000; + + // given + let relayer_db = + relayer_db_with_valid_relayed_txs(da_height, arb_large_max_gas); + + // when + let on_chain_db = database_with_genesis_block(genesis_da_height); + let producer = create_relayer_executor(on_chain_db, relayer_db); + let block = test_block(block_height.into(), da_height.into(), 0); + let (result, _) = producer + .execute_without_commit(ExecutionTypes::Production(block.into())) + .unwrap() + .into(); + + // then + let txs = result.block.transactions(); + assert_eq!(txs.len(), 2); + } + + fn relayer_db_with_valid_relayed_txs( + da_height: u64, + max_gas: u64, + ) -> Database { + let mut relayed_tx = RelayedTransaction::default(); + let tx = script_tx_for_amount(100); + let tx_bytes = tx.to_bytes(); + relayed_tx.set_serialized_transaction(tx_bytes); + relayed_tx.set_max_gas(max_gas); + + relayer_db_for_events(&[relayed_tx.into()], da_height) + } + + #[test] + fn execute_without_commit_with_coinbase__relayed_tx_execute_and_mint_will_have_no_fees( + ) { + let genesis_da_height = 3u64; + let block_height = 1u32; + let da_height = 10u64; + let gas_price = 1; + let arb_max_gas = 10_000; + + // given + let relayer_db = relayer_db_with_valid_relayed_txs(da_height, arb_max_gas); + + // when + let on_chain_db = database_with_genesis_block(genesis_da_height); + let producer = create_relayer_executor(on_chain_db, relayer_db); + let block = test_block(block_height.into(), da_height.into(), 0); + let (result, _) = producer + .execute_without_commit_with_coinbase( + ExecutionTypes::Production(block.into()), + Default::default(), + gas_price, + ) + .unwrap() + .into(); + + // then + let txs = result.block.transactions(); + assert_eq!(txs.len(), 2); + + // and + let mint = txs[1].as_mint().unwrap(); + assert_eq!(*mint.mint_amount(), 0); + } + + #[test] + fn execute_without_commit__duplicated_relayed_tx_not_included_in_block() { + let genesis_da_height = 3u64; + let block_height = 1u32; + let da_height = 10u64; + let duplicate_count = 10; + let arb_large_max_gas = 10_000; + + // given + let relayer_db = relayer_db_with_duplicate_valid_relayed_txs( + da_height, + duplicate_count, + arb_large_max_gas, + ); + + // when + let on_chain_db = database_with_genesis_block(genesis_da_height); + let producer = create_relayer_executor(on_chain_db, relayer_db); + let block = test_block(block_height.into(), da_height.into(), 0); + let (result, _) = producer + .execute_without_commit(ExecutionTypes::Production(block.into())) + .unwrap() + .into(); + + // then + let txs = result.block.transactions(); + assert_eq!(txs.len(), 2); + + // and + let events = result.events; + let count = events + .into_iter() + .filter(|event| { + matches!(event, ExecutorEvent::ForcedTransactionFailed { .. }) + }) + .count(); + assert_eq!(count, 10); + } + + fn relayer_db_with_duplicate_valid_relayed_txs( + da_height: u64, + duplicate_count: usize, + max_gas: u64, + ) -> Database { + let mut relayed_tx = RelayedTransaction::default(); + let tx = script_tx_for_amount(100); + let tx_bytes = tx.to_bytes(); + relayed_tx.set_serialized_transaction(tx_bytes); + relayed_tx.set_max_gas(max_gas); + let events = std::iter::repeat(relayed_tx.into()) + .take(duplicate_count + 1) + .collect::>(); + + relayer_db_for_events(&events, da_height) + } + + #[test] + fn execute_without_commit__invalid_relayed_txs_are_not_included_and_are_reported() + { + let genesis_da_height = 3u64; + let block_height = 1u32; + let da_height = 10u64; + let arb_large_max_gas = 10_000; + + // given + let relayer_db = + relayer_db_with_invalid_relayed_txs(da_height, arb_large_max_gas); + + // when + let on_chain_db = database_with_genesis_block(genesis_da_height); + let producer = create_relayer_executor(on_chain_db, relayer_db); + let block = test_block(block_height.into(), da_height.into(), 0); + let (result, _) = producer + .execute_without_commit(ExecutionTypes::Production(block.into())) + .unwrap() + .into(); + + // then + let txs = result.block.transactions(); + assert_eq!(txs.len(), 1); + + // and + let events = result.events; + let fuel_core_types::services::executor::Event::ForcedTransactionFailed { + failure: actual, + .. + } = &events[0] + else { + panic!("Expected `ForcedTransactionFailed` event") + }; + let expected = &ForcedTransactionFailure::CheckError(CheckError::Validity( + ValidityError::NoSpendableInput, + )) + .to_string(); + assert_eq!(expected, actual); + } + + fn relayer_db_with_invalid_relayed_txs( + da_height: u64, + max_gas: u64, + ) -> Database { + let event = arb_invalid_relayed_tx_event(max_gas); + relayer_db_for_events(&[event], da_height) + } + + #[test] + fn execute_without_commit__relayed_tx_with_low_max_gas_fails() { + let genesis_da_height = 3u64; + let block_height = 1u32; + let da_height = 10u64; + let zero_max_gas = 0; + + // given + let tx = script_tx_for_amount(100); + + let relayer_db = relayer_db_with_specific_tx_for_relayed_tx( + da_height, + tx.clone(), + zero_max_gas, + ); + + // when + let on_chain_db = database_with_genesis_block(genesis_da_height); + let producer = create_relayer_executor(on_chain_db, relayer_db); + let block = test_block(block_height.into(), da_height.into(), 0); + let (result, _) = producer + .execute_without_commit(ExecutionTypes::Production(block.into())) + .unwrap() + .into(); + + // then + let txs = result.block.transactions(); + assert_eq!(txs.len(), 1); + + // and + let consensus_params = ConsensusParameters::default(); + let actual_max_gas = tx + .as_script() + .unwrap() + .max_gas(consensus_params.gas_costs(), consensus_params.fee_params()); + let events = result.events; + let fuel_core_types::services::executor::Event::ForcedTransactionFailed { + failure: actual, + .. + } = &events[0] + else { + panic!("Expected `ForcedTransactionFailed` event") + }; + let expected = &ForcedTransactionFailure::InsufficientMaxGas { + claimed_max_gas: zero_max_gas, + actual_max_gas, + } + .to_string(); + assert_eq!(expected, actual); + } + + fn relayer_db_with_specific_tx_for_relayed_tx( + da_height: u64, + tx: Transaction, + max_gas: u64, + ) -> Database { + let mut relayed_tx = RelayedTransaction::default(); + let tx_bytes = tx.to_bytes(); + relayed_tx.set_serialized_transaction(tx_bytes); + relayed_tx.set_max_gas(max_gas); + relayer_db_for_events(&[relayed_tx.into()], da_height) + } + + #[test] + fn execute_without_commit__relayed_tx_that_passes_checks_but_fails_execution_is_reported( + ) { + let genesis_da_height = 3u64; + let block_height = 1u32; + let da_height = 10u64; + let arb_max_gas = 10_000; + + // given + let (tx_id, relayer_db) = + tx_id_and_relayer_db_with_tx_that_passes_checks_but_fails_execution( + da_height, + arb_max_gas, + ); + + // when + let on_chain_db = database_with_genesis_block(genesis_da_height); + let producer = create_relayer_executor(on_chain_db, relayer_db); + let block = test_block(block_height.into(), da_height.into(), 0); + let (result, _) = producer + .execute_without_commit(ExecutionTypes::Production(block.into())) + .unwrap() + .into(); + + // then + let txs = result.block.transactions(); + assert_eq!(txs.len(), 2); + + // and + let events = result.events; + let fuel_core_types::services::executor::Event::ForcedTransactionFailed { + failure: actual, + .. + } = &events[3] + else { + panic!("Expected `ForcedTransactionFailed` event") + }; + let expected = + &fuel_core_types::services::executor::Error::TransactionIdCollision( + tx_id, + ) + .to_string(); + assert_eq!(expected, actual); + } + + fn tx_id_and_relayer_db_with_tx_that_passes_checks_but_fails_execution( + da_height: u64, + max_gas: u64, + ) -> (Bytes32, Database) { + let mut relayed_tx = RelayedTransaction::default(); + let tx = script_tx_for_amount(100); + let tx_bytes = tx.to_bytes(); + relayed_tx.set_serialized_transaction(tx_bytes); + relayed_tx.set_max_gas(max_gas); + let mut bad_relayed_tx = relayed_tx.clone(); + let new_nonce = [9; 32].into(); + bad_relayed_tx.set_nonce(new_nonce); + let relayer_db = relayer_db_for_events( + &[relayed_tx.into(), bad_relayed_tx.into()], + da_height, + ); + (tx.id(&Default::default()), relayer_db) + } + + #[test] + fn execute_without_commit__validation__includes_status_of_failed_relayed_tx() { + let genesis_da_height = 3u64; + let block_height = 1u32; + let da_height = 10u64; + let arb_large_max_gas = 10_000; + + // given + let event = arb_invalid_relayed_tx_event(arb_large_max_gas); + let produced_block = produce_block_with_relayed_event( + event.clone(), + genesis_da_height, + block_height, + da_height, + ); + + // when + let verifyer_db = database_with_genesis_block(genesis_da_height); + let mut verifier_relayer_db = Database::::default(); + let events = vec![event]; + add_events_to_relayer(&mut verifier_relayer_db, da_height.into(), &events); + let verifier = create_relayer_executor(verifyer_db, verifier_relayer_db); + let (result, _) = verifier + .execute_without_commit(ExecutionTypes::Validation(produced_block)) + .unwrap() + .into(); + + // then + let txs = result.block.transactions(); + assert_eq!(txs.len(), 1); + + // and + let events = result.events; + let fuel_core_types::services::executor::Event::ForcedTransactionFailed { + failure: actual, + .. + } = &events[0] + else { + panic!("Expected `ForcedTransactionFailed` event") + }; + let expected = &ForcedTransactionFailure::CheckError(CheckError::Validity( + ValidityError::NoSpendableInput, + )) + .to_string(); + assert_eq!(expected, actual); + } + + fn produce_block_with_relayed_event( + event: Event, + genesis_da_height: u64, + block_height: u32, + da_height: u64, + ) -> Block { + let producer_db = database_with_genesis_block(genesis_da_height); + let producer_relayer_db = relayer_db_for_events(&[event], da_height); + + let producer = create_relayer_executor(producer_db, producer_relayer_db); + let block = test_block(block_height.into(), da_height.into(), 0); + let (produced_result, _) = producer + .execute_without_commit(ExecutionTypes::Production(block.into())) + .unwrap() + .into(); + produced_result.block + } + + fn arb_invalid_relayed_tx_event(max_gas: u64) -> Event { + let mut invalid_relayed_tx = RelayedTransaction::default(); + let mut tx = script_tx_for_amount(100); + tx.as_script_mut().unwrap().inputs_mut().drain(..); // Remove all the inputs :) + let tx_bytes = tx.to_bytes(); + invalid_relayed_tx.set_serialized_transaction(tx_bytes); + invalid_relayed_tx.set_max_gas(max_gas); + invalid_relayed_tx.into() + } + + #[test] + fn execute_without_commit__relayed_mint_tx_not_included_in_block() { + let genesis_da_height = 3u64; + let block_height = 1u32; + let da_height = 10u64; + let tx_count = 0; + + // given + let relayer_db = + relayer_db_with_mint_relayed_tx(da_height, block_height, tx_count); + + // when + let on_chain_db = database_with_genesis_block(genesis_da_height); + let producer = create_relayer_executor(on_chain_db, relayer_db); + let block = + test_block(block_height.into(), da_height.into(), tx_count as usize); + let (result, _) = producer + .execute_without_commit(ExecutionTypes::Production(block.into())) + .unwrap() + .into(); + + // then + let txs = result.block.transactions(); + assert_eq!(txs.len(), 1); + + // and + let events = result.events; + let fuel_core_types::services::executor::Event::ForcedTransactionFailed { + failure: actual, + .. + } = &events[0] + else { + panic!("Expected `ForcedTransactionFailed` event") + }; + let expected = &ForcedTransactionFailure::InvalidTransactionType.to_string(); + assert_eq!(expected, actual); + } + + fn relayer_db_with_mint_relayed_tx( + da_height: u64, + block_height: u32, + tx_count: u16, + ) -> Database { + let mut relayed_tx = RelayedTransaction::default(); + let base_asset_id = AssetId::BASE; + let mint = Transaction::mint( + TxPointer::new(block_height.into(), tx_count), + contract::Contract { + utxo_id: UtxoId::new(Bytes32::zeroed(), 0), + balance_root: Bytes32::zeroed(), + state_root: Bytes32::zeroed(), + tx_pointer: TxPointer::new(BlockHeight::new(0), 0), + contract_id: ContractId::zeroed(), + }, + output::contract::Contract { + input_index: 0, + balance_root: Bytes32::zeroed(), + state_root: Bytes32::zeroed(), + }, + 0, + base_asset_id, + 0, + ); + let tx = Transaction::Mint(mint); + let tx_bytes = tx.to_bytes(); + relayed_tx.set_serialized_transaction(tx_bytes); + relayer_db_for_events(&[relayed_tx.into()], da_height) + } + + fn relayer_db_for_events(events: &[Event], da_height: u64) -> Database { + let mut relayer_db = Database::::default(); + add_events_to_relayer(&mut relayer_db, da_height.into(), events); + relayer_db + } + + #[test] + fn execute_without_commit__relayed_tx_can_spend_message_from_same_da_block() { + let genesis_da_height = 3u64; + let block_height = 1u32; + let da_height = 10u64; + let arb_max_gas = 10_000; + + // given + let relayer_db = + relayer_db_with_relayed_tx_spending_message_from_same_da_block( + da_height, + arb_max_gas, + ); + + // when + let on_chain_db = database_with_genesis_block(genesis_da_height); + let producer = create_relayer_executor(on_chain_db, relayer_db); + let block = test_block(block_height.into(), da_height.into(), 0); + let (result, _) = producer + .execute_without_commit(ExecutionBlock::Production(block.into())) + .unwrap() + .into(); + + // then + let txs = result.block.transactions(); + assert_eq!(txs.len(), 2); + } + + fn relayer_db_with_relayed_tx_spending_message_from_same_da_block( + da_height: u64, + max_gas: u64, + ) -> Database { + let mut relayer_db = Database::::default(); + let mut message = Message::default(); + let nonce = 1.into(); + message.set_da_height(da_height.into()); + message.set_nonce(nonce); + let message_event = Event::Message(message); + + let mut relayed_tx = RelayedTransaction::default(); + let tx = TransactionBuilder::script(vec![], vec![]) + .script_gas_limit(10) + .add_unsigned_message_input( + SecretKey::random(&mut StdRng::seed_from_u64(2322)), + Default::default(), + nonce, + Default::default(), + vec![], + ) + .finalize_as_transaction(); + let tx_bytes = tx.to_bytes(); + relayed_tx.set_serialized_transaction(tx_bytes); + relayed_tx.set_max_gas(max_gas); + let tx_event = Event::Transaction(relayed_tx); + add_events_to_relayer( + &mut relayer_db, + da_height.into(), + &[message_event, tx_event], + ); + relayer_db + } + #[test] fn block_producer_does_not_take_messages_for_the_same_height() { let genesis_da_height = 1u64; diff --git a/crates/fuel-core/src/graphql_api/database.rs b/crates/fuel-core/src/graphql_api/database.rs index 35706a6141..30a3c619b3 100644 --- a/crates/fuel-core/src/graphql_api/database.rs +++ b/crates/fuel-core/src/graphql_api/database.rs @@ -6,6 +6,7 @@ use crate::fuel_core_graphql_api::{ DatabaseContracts, DatabaseMessageProof, DatabaseMessages, + DatabaseRelayedTransactions, OffChainDatabase, OnChainDatabase, }, @@ -33,13 +34,17 @@ use fuel_core_types::{ DaBlockHeight, }, }, - entities::relayer::message::{ - MerkleProof, - Message, + entities::relayer::{ + message::{ + MerkleProof, + Message, + }, + transaction::RelayedTransactionStatus, }, fuel_tx::{ Address, AssetId, + Bytes32, Salt, TxPointer, UtxoId, @@ -154,6 +159,16 @@ impl DatabaseMessages for ReadView { } } +impl DatabaseRelayedTransactions for ReadView { + fn transaction_status( + &self, + id: Bytes32, + ) -> StorageResult> { + let maybe_status = self.off_chain.relayed_tx_status(id)?; + Ok(maybe_status) + } +} + impl DatabaseContracts for ReadView { fn contract_balances( &self, @@ -226,4 +241,11 @@ impl OffChainDatabase for ReadView { fn contract_salt(&self, contract_id: &ContractId) -> StorageResult { self.off_chain.contract_salt(contract_id) } + + fn relayed_tx_status( + &self, + id: Bytes32, + ) -> StorageResult> { + self.off_chain.relayed_tx_status(id) + } } diff --git a/crates/fuel-core/src/graphql_api/ports.rs b/crates/fuel-core/src/graphql_api/ports.rs index e13349fe09..bd0fc04db2 100644 --- a/crates/fuel-core/src/graphql_api/ports.rs +++ b/crates/fuel-core/src/graphql_api/ports.rs @@ -27,11 +27,15 @@ use fuel_core_types::{ DaBlockHeight, }, }, - entities::relayer::message::{ - MerkleProof, - Message, + entities::relayer::{ + message::{ + MerkleProof, + Message, + }, + transaction::RelayedTransactionStatus, }, fuel_tx::{ + Bytes32, ConsensusParameters, Salt, Transaction, @@ -86,6 +90,11 @@ pub trait OffChainDatabase: Send + Sync { ) -> BoxedIter>; fn contract_salt(&self, contract_id: &ContractId) -> StorageResult; + + fn relayed_tx_status( + &self, + id: Bytes32, + ) -> StorageResult>; } /// The on chain database port expected by GraphQL API service. @@ -129,6 +138,13 @@ pub trait DatabaseMessages: StorageInspect { fn message_exists(&self, nonce: &Nonce) -> StorageResult; } +pub trait DatabaseRelayedTransactions { + fn transaction_status( + &self, + id: Bytes32, + ) -> StorageResult>; +} + /// Trait that specifies all the getters required for contract. pub trait DatabaseContracts: StorageInspect @@ -208,10 +224,13 @@ pub trait GasPriceEstimate: Send + Sync { pub mod worker { use super::super::storage::blocks::FuelBlockIdsToHeights; - use crate::fuel_core_graphql_api::storage::{ - coins::OwnedCoins, - contracts::ContractsInfo, - messages::OwnedMessageIds, + use crate::{ + fuel_core_graphql_api::storage::{ + coins::OwnedCoins, + contracts::ContractsInfo, + messages::OwnedMessageIds, + }, + graphql_api::storage::relayed_transactions::RelayedTransactionStatuses, }; use fuel_core_services::stream::BoxStream; use fuel_core_storage::{ @@ -245,6 +264,7 @@ pub mod worker { + StorageMutate + StorageMutate + StorageMutate + + StorageMutate { fn record_tx_id_owner( &mut self, diff --git a/crates/fuel-core/src/graphql_api/storage.rs b/crates/fuel-core/src/graphql_api/storage.rs index 887f0b385d..5983b65ae6 100644 --- a/crates/fuel-core/src/graphql_api/storage.rs +++ b/crates/fuel-core/src/graphql_api/storage.rs @@ -43,6 +43,7 @@ pub mod messages; pub mod statistic; pub mod transactions; +pub mod relayed_transactions; /// Tracks the total number of transactions written to the chain /// It's useful for analyzing TPS or other metrics. const TX_COUNT: &str = "total_tx_count"; @@ -79,6 +80,8 @@ pub enum Column { FuelBlockIdsToHeights = 7, /// See [`ContractsInfo`](contracts::ContractsInfo) ContractsInfo = 8, + /// Relayed Tx ID to Layer 1 Relayed Transaction status + RelayedTransactionStatus = 9, } impl Column { diff --git a/crates/fuel-core/src/graphql_api/storage/relayed_transactions.rs b/crates/fuel-core/src/graphql_api/storage/relayed_transactions.rs new file mode 100644 index 0000000000..c704714313 --- /dev/null +++ b/crates/fuel-core/src/graphql_api/storage/relayed_transactions.rs @@ -0,0 +1,69 @@ +use fuel_core_chain_config::{ + AddTable, + AsTable, + StateConfig, + StateConfigBuilder, + TableEntry, +}; +use fuel_core_storage::{ + blueprint::plain::Plain, + codec::{ + postcard::Postcard, + raw::Raw, + }, + structured_storage::TableWithBlueprint, + Mappable, +}; +use fuel_core_types::{ + entities::relayer::transaction::RelayedTransactionStatus, + fuel_tx::Bytes32, +}; + +/// Tracks the status of transactions from the L1. These are tracked separately from tx-pool +/// transactions because they might fail as part of the relay process, not just due +/// to execution. +pub struct RelayedTransactionStatuses; + +impl Mappable for RelayedTransactionStatuses { + type Key = Bytes32; + type OwnedKey = Self::Key; + type Value = RelayedTransactionStatus; + type OwnedValue = Self::Value; +} + +impl TableWithBlueprint for RelayedTransactionStatuses { + type Blueprint = Plain; + + type Column = super::Column; + + fn column() -> Self::Column { + Self::Column::RelayedTransactionStatus + } +} + +impl AsTable for StateConfig { + fn as_table(&self) -> Vec> { + Vec::new() // Do not include these for now + } +} + +impl AddTable for StateConfigBuilder { + fn add(&mut self, _entries: Vec>) { + // Do not include these for now + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fuel_core_types::fuel_tx::Bytes32; + + fuel_core_storage::basic_storage_tests!( + RelayedTransactionStatuses, + ::Key::from(Bytes32::default()), + RelayedTransactionStatus::Failed { + block_height: 0.into(), + failure: "Some reason".to_string(), + } + ); +} diff --git a/crates/fuel-core/src/graphql_api/worker_service.rs b/crates/fuel-core/src/graphql_api/worker_service.rs index 320aa2c053..f743d4fce9 100644 --- a/crates/fuel-core/src/graphql_api/worker_service.rs +++ b/crates/fuel-core/src/graphql_api/worker_service.rs @@ -1,18 +1,21 @@ -use crate::fuel_core_graphql_api::{ - ports, - ports::worker::OffChainDatabase, - storage::{ - blocks::FuelBlockIdsToHeights, - coins::{ - owner_coin_id_key, - OwnedCoins, - }, - contracts::ContractsInfo, - messages::{ - OwnedMessageIds, - OwnedMessageKey, +use crate::{ + fuel_core_graphql_api::{ + ports, + ports::worker::OffChainDatabase, + storage::{ + blocks::FuelBlockIdsToHeights, + coins::{ + owner_coin_id_key, + OwnedCoins, + }, + contracts::ContractsInfo, + messages::{ + OwnedMessageIds, + OwnedMessageKey, + }, }, }, + graphql_api::storage::relayed_transactions::RelayedTransactionStatuses, }; use fuel_core_metrics::graphql_metrics::graphql_metrics; use fuel_core_services::{ @@ -29,6 +32,7 @@ use fuel_core_storage::{ }; use fuel_core_types::{ blockchain::block::Block, + entities::relayer::transaction::RelayedTransactionStatus, fuel_tx::{ field::{ Inputs, @@ -70,6 +74,9 @@ use std::{ ops::Deref, }; +#[cfg(test)] +mod tests; + /// The off-chain GraphQL API worker task processes the imported blocks /// and actualize the information used by the GraphQL service. pub struct Task { @@ -165,6 +172,20 @@ where .storage_as_mut::() .remove(&key)?; } + Event::ForcedTransactionFailed { + id, + block_height, + failure, + } => { + let status = RelayedTransactionStatus::Failed { + block_height: *block_height, + failure: failure.clone(), + }; + + block_st_transaction + .storage_as_mut::() + .insert(&Bytes32::from(id.to_owned()), &status)?; + } } } Ok(()) diff --git a/crates/fuel-core/src/graphql_api/worker_service/tests.rs b/crates/fuel-core/src/graphql_api/worker_service/tests.rs new file mode 100644 index 0000000000..e0b9821011 --- /dev/null +++ b/crates/fuel-core/src/graphql_api/worker_service/tests.rs @@ -0,0 +1,85 @@ +#![allow(non_snake_case)] + +use super::*; +use crate::{ + database::Database, + graphql_api::storage::relayed_transactions::RelayedTransactionStatuses, +}; +use fuel_core_services::stream::IntoBoxStream; +use fuel_core_storage::StorageAsRef; +use fuel_core_types::{ + fuel_tx::Bytes32, + fuel_types::BlockHeight, + services::txpool::TransactionStatus, +}; +use std::sync::Arc; + +struct MockTxPool; + +impl ports::worker::TxPool for MockTxPool { + fn send_complete( + &self, + _id: Bytes32, + _block_height: &BlockHeight, + _status: TransactionStatus, + ) { + // Do nothing + } +} + +#[tokio::test] +async fn run__relayed_transaction_events_are_added_to_storage() { + let tx_id: Bytes32 = [1; 32].into(); + let block_height = 8.into(); + let failure = "blah blah blah".to_string(); + let database = Database::in_memory(); + let mut state_watcher = StateWatcher::started(); + + // given + let event = Event::ForcedTransactionFailed { + id: tx_id.into(), + block_height, + failure: failure.clone(), + }; + let block_importer = block_importer_for_event(event); + + // when + let mut task = + worker_task_with_block_importer_and_db(block_importer, database.clone()); + task.run(&mut state_watcher).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // then + let expected = RelayedTransactionStatus::Failed { + block_height, + failure, + }; + let storage = database.storage_as_ref::(); + let actual = storage.get(&tx_id).unwrap().unwrap(); + assert_eq!(*actual, expected); +} + +fn block_importer_for_event(event: Event) -> BoxStream { + let block = Arc::new(ImportResult { + sealed_block: Default::default(), + tx_status: vec![], + events: vec![event], + source: Default::default(), + }); + let blocks: Vec + Send + Sync>> = vec![block]; + tokio_stream::iter(blocks).into_boxed() +} + +fn worker_task_with_block_importer_and_db( + block_importer: BoxStream, + database: D, +) -> Task { + let tx_pool = MockTxPool; + let chain_id = Default::default(); + Task { + tx_pool, + block_importer, + database, + chain_id, + } +} diff --git a/crates/fuel-core/src/schema.rs b/crates/fuel-core/src/schema.rs index f1df9c6d4f..e4185d5bcf 100644 --- a/crates/fuel-core/src/schema.rs +++ b/crates/fuel-core/src/schema.rs @@ -33,6 +33,8 @@ pub mod gas_price; pub mod scalars; pub mod tx; +pub mod relayed_tx; + #[derive(MergedObject, Default)] pub struct Query( dap::DapQuery, @@ -48,6 +50,7 @@ pub struct Query( gas_price::LatestGasPriceQuery, gas_price::EstimateGasPriceQuery, message::MessageQuery, + relayed_tx::RelayedTransactionQuery, ); #[derive(MergedObject, Default)] diff --git a/crates/fuel-core/src/schema/relayed_tx.rs b/crates/fuel-core/src/schema/relayed_tx.rs new file mode 100644 index 0000000000..b219280ebb --- /dev/null +++ b/crates/fuel-core/src/schema/relayed_tx.rs @@ -0,0 +1,72 @@ +use crate::{ + fuel_core_graphql_api::{ + database::ReadView, + ports::DatabaseRelayedTransactions, + }, + schema::scalars::{ + RelayedTransactionId, + U32, + }, +}; +use async_graphql::{ + Context, + Object, + Union, +}; +use fuel_core_types::{ + entities::relayer::transaction::RelayedTransactionStatus as FuelRelayedTransactionStatus, + fuel_types::BlockHeight, +}; + +#[derive(Default)] +pub struct RelayedTransactionQuery {} + +#[Object] +impl RelayedTransactionQuery { + async fn relayed_transaction_status( + &self, + ctx: &Context<'_>, + #[graphql(desc = "The id of the relayed tx")] id: RelayedTransactionId, + ) -> async_graphql::Result> { + let query: &ReadView = ctx.data_unchecked(); + let status = query.transaction_status(id.0)?.map(|status| status.into()); + Ok(status) + } +} + +#[derive(Union, Debug)] +pub enum RelayedTransactionStatus { + Failed(RelayedTransactionFailed), +} + +#[derive(Debug)] +pub struct RelayedTransactionFailed { + pub block_height: BlockHeight, + pub failure: String, +} + +#[Object] +impl RelayedTransactionFailed { + async fn block_height(&self) -> U32 { + let as_u32: u32 = self.block_height.into(); + as_u32.into() + } + + async fn failure(&self) -> String { + self.failure.clone() + } +} + +impl From for RelayedTransactionStatus { + fn from(status: FuelRelayedTransactionStatus) -> Self { + match status { + FuelRelayedTransactionStatus::Failed { + block_height, + failure, + } => RelayedTransactionStatus::Failed(RelayedTransactionFailed { + block_height, + failure, + }), + } + } +} diff --git a/crates/fuel-core/src/schema/scalars.rs b/crates/fuel-core/src/schema/scalars.rs index 2b95ee7962..4ead3d0b6e 100644 --- a/crates/fuel-core/src/schema/scalars.rs +++ b/crates/fuel-core/src/schema/scalars.rs @@ -299,6 +299,7 @@ fuel_type_scalar!("AssetId", AssetId, AssetId, 32); fuel_type_scalar!("ContractId", ContractId, ContractId, 32); fuel_type_scalar!("Salt", Salt, Salt, 32); fuel_type_scalar!("TransactionId", TransactionId, Bytes32, 32); +fuel_type_scalar!("RelayedTransactionId", RelayedTransactionId, Bytes32, 32); fuel_type_scalar!("MessageId", MessageId, MessageId, 32); fuel_type_scalar!("Nonce", Nonce, Nonce, 32); fuel_type_scalar!("Signature", Signature, Bytes64, 64); diff --git a/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs b/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs index ce5645a3e6..887ddaccc3 100644 --- a/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs +++ b/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs @@ -10,6 +10,7 @@ use crate::{ }, storage::{ contracts::ContractsInfo, + relayed_transactions::RelayedTransactionStatuses, transactions::OwnedTransactionIndexCursor, }, }, @@ -35,8 +36,10 @@ use fuel_core_txpool::types::{ }; use fuel_core_types::{ blockchain::primitives::BlockId, + entities::relayer::transaction::RelayedTransactionStatus, fuel_tx::{ Address, + Bytes32, Salt, TxPointer, UtxoId, @@ -106,6 +109,18 @@ impl OffChainDatabase for Database { Ok(salt) } + + fn relayed_tx_status( + &self, + id: Bytes32, + ) -> StorageResult> { + let status = self + .storage_as_ref::() + .get(&id) + .map_err(StorageError::from)? + .map(|cow| cow.into_owned()); + Ok(status) + } } impl Transactional for Database { diff --git a/crates/services/executor/src/executor.rs b/crates/services/executor/src/executor.rs index 9daf8ae097..d2ad053089 100644 --- a/crates/services/executor/src/executor.rs +++ b/crates/services/executor/src/executor.rs @@ -48,6 +48,7 @@ use fuel_core_types::{ CompressedCoinV1, }, contract::ContractUtxoInfo, + RelayedTransaction, }, fuel_asm::{ RegId, @@ -95,6 +96,7 @@ use fuel_core_types::{ UtxoId, }, fuel_types::{ + canonical::Deserialize, BlockHeight, ContractId, MessageId, @@ -110,7 +112,7 @@ use fuel_core_types::{ IntoChecked, }, interpreter::{ - CheckedMetadata, + CheckedMetadata as CheckedMetadataTrait, ExecutableTransaction, InterpreterParams, }, @@ -127,6 +129,7 @@ use fuel_core_types::{ ExecutionResult, ExecutionType, ExecutionTypes, + ForcedTransactionFailure, Result as ExecutorResult, TransactionExecutionResult, TransactionExecutionStatus, @@ -470,19 +473,20 @@ where let source = component.transactions_source; let gas_price = component.gas_price; let coinbase_contract_id = component.coinbase_contract_id; - let mut remaining_gas_limit = block_gas_limit; let block_height = *block.header.height(); - if self.relayer.enabled() { - self.process_da(&block.header, execution_data)?; - } + let forced_transactions = if self.relayer.enabled() { + self.process_da(&block.header, execution_data)? + } else { + Vec::with_capacity(0) + }; // The block level storage transaction that also contains data from the relayer. // Starting from this point, modifications from each thread should be independent // and shouldn't touch the same data. let mut block_with_relayer_data_transaction = self.block_st_transaction.read_transaction() - // Enforces independent changes from each thread. - .with_policy(ConflictPolicy::Fail); + // Enforces independent changes from each thread. + .with_policy(ConflictPolicy::Fail); // We execute transactions in a single thread right now, but later, // we will execute them in parallel with a separate independent storage transaction per thread. @@ -490,13 +494,11 @@ where .read_transaction() .with_policy(ConflictPolicy::Overwrite); - // ALl transactions should be in the `TxSource`. - // We use `block.transactions` to store executed transactions. debug_assert!(block.transactions.is_empty()); - let mut iter = source.next(remaining_gas_limit).into_iter().peekable(); let mut execute_transaction = |execution_data: &mut ExecutionData, - tx: MaybeCheckedTransaction| + tx: MaybeCheckedTransaction, + gas_price: Word| -> ExecutorResult<()> { let tx_count = execution_data.tx_count; let tx = { @@ -504,7 +506,7 @@ where .write_transaction() .with_policy(ConflictPolicy::Overwrite); let tx_id = tx.id(&self.consensus_params.chain_id()); - let result = self.execute_transaction( + let tx = self.execute_transaction( tx, &tx_id, &block.header, @@ -513,31 +515,8 @@ where execution_data, execution_kind, &mut tx_st_transaction, - ); - - let tx = match result { - Err(err) => { - return match execution_kind { - ExecutionKind::Production => { - // If, during block production, we get an invalid transaction, - // remove it from the block and continue block creation. An invalid - // transaction means that the caller didn't validate it first, so - // maybe something is wrong with validation rules in the `TxPool` - // (or in another place that should validate it). Or we forgot to - // clean up some dependent/conflict transactions. But it definitely - // means that something went wrong, and we must fix it. - execution_data.skipped_transactions.push((tx_id, err)); - Ok(()) - } - ExecutionKind::DryRun | ExecutionKind::Validation => Err(err), - } - } - Ok(tx) => tx, - }; - - if let Err(err) = tx_st_transaction.commit() { - return Err(err.into()) - } + )?; + tx_st_transaction.commit()?; tx }; @@ -549,14 +528,53 @@ where Ok(()) }; - while iter.peek().is_some() { - for transaction in iter { - execute_transaction(&mut *execution_data, transaction)?; + let relayed_tx_iter = forced_transactions.into_iter(); + for transaction in relayed_tx_iter { + const RELAYED_GAS_PRICE: Word = 0; + let transaction = MaybeCheckedTransaction::CheckedTransaction(transaction); + let tx_id = transaction.id(&self.consensus_params.chain_id()); + match execute_transaction( + &mut *execution_data, + transaction, + RELAYED_GAS_PRICE, + ) { + Ok(_) => {} + Err(err) => { + let event = ExecutorEvent::ForcedTransactionFailed { + id: tx_id.into(), + block_height, + failure: err.to_string(), + }; + execution_data.events.push(event); + } } + } - remaining_gas_limit = block_gas_limit.saturating_sub(execution_data.used_gas); + let remaining_gas_limit = block_gas_limit.saturating_sub(execution_data.used_gas); + + // L2 originated transactions should be in the `TxSource`. This will be triggered after + // all relayed transactions are processed. + let mut regular_tx_iter = source.next(remaining_gas_limit).into_iter().peekable(); + while regular_tx_iter.peek().is_some() { + for transaction in regular_tx_iter { + let tx_id = transaction.id(&self.consensus_params.chain_id()); + match execute_transaction(&mut *execution_data, transaction, gas_price) { + Ok(_) => {} + Err(err) => match execution_kind { + ExecutionKind::Production => { + execution_data.skipped_transactions.push((tx_id, err)); + } + ExecutionKind::DryRun | ExecutionKind::Validation => { + return Err(err); + } + }, + } + } + + let new_remaining_gas_limit = + block_gas_limit.saturating_sub(execution_data.used_gas); - iter = source.next(remaining_gas_limit).into_iter().peekable(); + regular_tx_iter = source.next(new_remaining_gas_limit).into_iter().peekable(); } // After the execution of all transactions in production mode, we can set the final fee. @@ -589,6 +607,7 @@ where execute_transaction( execution_data, MaybeCheckedTransaction::Transaction(coinbase_tx.into()), + gas_price, )?; } @@ -610,7 +629,7 @@ where &mut self, header: &PartialBlockHeader, execution_data: &mut ExecutionData, - ) -> ExecutorResult<()> { + ) -> ExecutorResult> { let block_height = *header.height(); let prev_block_height = block_height .pred() @@ -628,6 +647,8 @@ where let mut root_calculator = MerkleRootCalculator::new(); + let mut checked_forced_txs = vec![]; + for da_height in next_unprocessed_da_height..=header.da_height.0 { let da_height = da_height.into(); let events = self @@ -648,8 +669,29 @@ where .events .push(ExecutorEvent::MessageImported(message)); } - Event::Transaction(_) => { - // TODO: implement handling of forced transactions in a later PR + Event::Transaction(relayed_tx) => { + let id = relayed_tx.id(); + // perform basic checks + let checked_tx_res = Self::validate_forced_tx( + relayed_tx, + header, + &self.consensus_params, + ); + // handle the result + match checked_tx_res { + Ok(checked_tx) => { + checked_forced_txs.push(checked_tx); + } + Err(err) => { + execution_data.events.push( + ExecutorEvent::ForcedTransactionFailed { + id, + block_height, + failure: err.to_string(), + }, + ); + } + } } } } @@ -657,6 +699,75 @@ where execution_data.event_inbox_root = root_calculator.root().into(); + Ok(checked_forced_txs) + } + + /// Parse forced transaction payloads and perform basic checks + fn validate_forced_tx( + relayed_tx: RelayedTransaction, + header: &PartialBlockHeader, + consensus_params: &ConsensusParameters, + ) -> Result { + let parsed_tx = Self::parse_tx_bytes(&relayed_tx)?; + Self::tx_is_valid_variant(&parsed_tx)?; + Self::relayed_tx_claimed_enough_max_gas( + &parsed_tx, + &relayed_tx, + consensus_params, + )?; + let checked_tx = + Self::get_checked_tx(parsed_tx, *header.height(), consensus_params)?; + Ok(CheckedTransaction::from(checked_tx)) + } + + fn parse_tx_bytes( + relayed_transaction: &RelayedTransaction, + ) -> Result { + let tx_bytes = relayed_transaction.serialized_transaction(); + let tx = Transaction::from_bytes(tx_bytes) + .map_err(|_| ForcedTransactionFailure::CodecError)?; + Ok(tx) + } + + fn get_checked_tx( + tx: Transaction, + height: BlockHeight, + consensus_params: &ConsensusParameters, + ) -> Result, ForcedTransactionFailure> { + let checked_tx = tx + .into_checked(height, consensus_params) + .map_err(ForcedTransactionFailure::CheckError)?; + Ok(checked_tx) + } + + fn tx_is_valid_variant(tx: &Transaction) -> Result<(), ForcedTransactionFailure> { + match tx { + Transaction::Mint(_) => Err(ForcedTransactionFailure::InvalidTransactionType), + Transaction::Script(_) | Transaction::Create(_) => Ok(()), + } + } + + fn relayed_tx_claimed_enough_max_gas( + tx: &Transaction, + relayed_tx: &RelayedTransaction, + consensus_params: &ConsensusParameters, + ) -> Result<(), ForcedTransactionFailure> { + let claimed_max_gas = relayed_tx.max_gas(); + let gas_costs = consensus_params.gas_costs(); + let fee_params = consensus_params.fee_params(); + let actual_max_gas = match tx { + Transaction::Script(script) => script.max_gas(gas_costs, fee_params), + Transaction::Create(create) => create.max_gas(gas_costs, fee_params), + Transaction::Mint(_) => { + return Err(ForcedTransactionFailure::InvalidTransactionType) + } + }; + if actual_max_gas > claimed_max_gas { + return Err(ForcedTransactionFailure::InsufficientMaxGas { + claimed_max_gas, + actual_max_gas, + }); + } Ok(()) } @@ -895,7 +1006,7 @@ where ) -> ExecutorResult where Tx: ExecutableTransaction + PartialEq + Cacheable + Send + Sync + 'static, - ::Metadata: CheckedMetadata + Clone + Send + Sync, + ::Metadata: CheckedMetadataTrait + Clone + Send + Sync, T: KeyValueInspect, { let tx_id = checked_tx.id(); @@ -1139,7 +1250,7 @@ where if db.storage::().contains_key(nonce)? { return Err( TransactionValidityError::MessageAlreadySpent(*nonce).into() - ) + ); } if let Some(message) = db.storage::().get(nonce)? { if message.da_height() > block_da_height { diff --git a/crates/types/src/entities/relayer/transaction.rs b/crates/types/src/entities/relayer/transaction.rs index 3bcc3cb0dd..8a924132d2 100644 --- a/crates/types/src/entities/relayer/transaction.rs +++ b/crates/types/src/entities/relayer/transaction.rs @@ -4,6 +4,7 @@ use crate::{ blockchain::primitives::DaBlockHeight, fuel_crypto, fuel_types::{ + BlockHeight, Bytes32, Nonce, }, @@ -113,7 +114,6 @@ impl RelayedTransaction { } } - #[cfg(any(test, feature = "test-helpers"))] /// Get the canonically serialized transaction pub fn serialized_transaction(&self) -> &[u8] { match self { @@ -148,3 +148,16 @@ impl From for RelayedTransaction { RelayedTransaction::V1(relayed_transaction) } } + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +/// Potential states for the relayed transaction +pub enum RelayedTransactionStatus { + /// Transaction was included in a block, but the execution was reverted + Failed { + /// The height of the block that processed this transaction + block_height: BlockHeight, + /// The actual failure reason for why the forced transaction was not included + failure: String, + }, +} diff --git a/crates/types/src/services/executor.rs b/crates/types/src/services/executor.rs index d8c10c1f17..dd456a706f 100644 --- a/crates/types/src/services/executor.rs +++ b/crates/types/src/services/executor.rs @@ -11,7 +11,10 @@ use crate::{ }, entities::{ coins::coin::Coin, - relayer::message::Message, + relayer::{ + message::Message, + transaction::RelayedTransactionId, + }, }, fuel_tx::{ Receipt, @@ -20,6 +23,7 @@ use crate::{ ValidityError, }, fuel_types::{ + BlockHeight, Bytes32, ContractId, Nonce, @@ -64,6 +68,44 @@ pub enum Event { CoinCreated(Coin), /// The coin was consumed by the transaction. CoinConsumed(Coin), + /// Failed transaction inclusion + ForcedTransactionFailed { + /// The hash of the relayed transaction + id: RelayedTransactionId, + /// The height of the block that processed this transaction + block_height: BlockHeight, + /// The actual failure reason for why the forced transaction was not included + failure: String, + }, +} + +/// Known failure modes for processing forced transactions +#[derive(Debug, derive_more::Display)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] +pub enum ForcedTransactionFailure { + /// Failed to decode transaction to a valid fuel_tx::Transaction + #[display(fmt = "Failed to decode transaction")] + CodecError, + /// Transaction failed basic checks + #[display(fmt = "Failed validity checks: {_0:?}")] + CheckError(CheckError), + /// Invalid transaction type + #[display(fmt = "Transaction type is not accepted")] + InvalidTransactionType, + /// Execution error which failed to include + #[display(fmt = "Transaction inclusion failed {_0}")] + ExecutionError(Error), + /// Relayed Transaction didn't specify high enough max gas + #[display( + fmt = "Insufficient max gas: Expected: {claimed_max_gas:?}, Actual: {actual_max_gas:?}" + )] + InsufficientMaxGas { + /// The max gas claimed by the L1 transaction submitter + claimed_max_gas: u64, + /// The actual max gas used by the transaction + actual_max_gas: u64, + }, } /// The status of a transaction after it is executed. @@ -292,7 +334,7 @@ impl ExecutionKind { } #[allow(missing_docs)] -#[derive(Debug, PartialEq, derive_more::Display, derive_more::From)] +#[derive(Debug, Clone, PartialEq, derive_more::Display, derive_more::From)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[non_exhaustive] pub enum Error { @@ -382,7 +424,7 @@ impl From for Error { } #[allow(missing_docs)] -#[derive(thiserror::Error, Debug, PartialEq)] +#[derive(thiserror::Error, Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[non_exhaustive] pub enum TransactionValidityError { diff --git a/crates/types/src/services/txpool.rs b/crates/types/src/services/txpool.rs index 069b7e0a72..5259f4c3da 100644 --- a/crates/types/src/services/txpool.rs +++ b/crates/types/src/services/txpool.rs @@ -197,7 +197,7 @@ pub enum TransactionStatus { /// Why this happened reason: String, }, - /// Transaction was included in a block, but the exection was reverted + /// Transaction was included in a block, but the execution was reverted Failed { /// Included in this block block_height: BlockHeight, diff --git a/tests/tests/relayer.rs b/tests/tests/relayer.rs index ecf010e990..8a5434dfc4 100644 --- a/tests/tests/relayer.rs +++ b/tests/tests/relayer.rs @@ -7,7 +7,9 @@ use ethers::{ }, }; use fuel_core::{ + combined_database::CombinedDatabase, database::Database, + fuel_core_graphql_api::storage::relayed_transactions::RelayedTransactionStatuses, relayer, service::{ Config, @@ -20,7 +22,10 @@ use fuel_core_client::client::{ PageDirection, PaginationRequest, }, - types::TransactionStatus, + types::{ + RelayedTransactionStatus as ClientRelayedTransactionStatus, + TransactionStatus, + }, FuelClient, }; use fuel_core_poa::service::Mode; @@ -34,13 +39,18 @@ use fuel_core_relayer::{ }; use fuel_core_storage::{ tables::Messages, + StorageAsMut, StorageAsRef, }; use fuel_core_types::{ + entities::relayer::transaction::RelayedTransactionStatus as FuelRelayedTransactionStatus, fuel_asm::*, fuel_crypto::*, fuel_tx::*, - fuel_types::Nonce, + fuel_types::{ + BlockHeight, + Nonce, + }, }; use hyper::{ service::{ @@ -252,6 +262,38 @@ async fn messages_are_spendable_after_relayer_is_synced() { eth_node_handle.shutdown.send(()).unwrap(); } +#[tokio::test(flavor = "multi_thread")] +async fn can_find_failed_relayed_tx() { + let mut db = CombinedDatabase::in_memory(); + let id = [1; 32].into(); + let block_height: BlockHeight = 999.into(); + let failure = "lolz".to_string(); + + // given + let status = FuelRelayedTransactionStatus::Failed { + block_height, + failure: failure.clone(), + }; + db.off_chain_mut() + .storage_as_mut::() + .insert(&id, &status) + .unwrap(); + + // when + let srv = FuelService::from_combined_database(db.clone(), Config::local_node()) + .await + .unwrap(); + let client = FuelClient::from(srv.bound_address); + + // then + let expected = Some(ClientRelayedTransactionStatus::Failed { + block_height, + failure, + }); + let actual = client.relayed_transaction_status(&id).await.unwrap(); + assert_eq!(expected, actual); +} + #[allow(clippy::too_many_arguments)] fn make_message_event( nonce: Nonce,