diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df760c1429..f70e425d928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Description of the upcoming release here. - [#1263](https://github.com/FuelLabs/fuel-core/pull/1263): Add gas benchmarks for `ED19` and `ECR1` instructions. - [#1331](https://github.com/FuelLabs/fuel-core/pull/1331): Add peer reputation reporting to block import code - [#1405](https://github.com/FuelLabs/fuel-core/pull/1405): Use correct names for service metrics. +- [#1501](https://github.com/FuelLabs/fuel-core/pull/1501): Add a CLI command for generating a fee collection contract. ### Changed diff --git a/Cargo.lock b/Cargo.lock index c1cf36b1949..92e4e9fd5b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2833,6 +2833,9 @@ dependencies = [ "dirs 4.0.0", "dotenvy", "fuel-core", + "fuel-core-chain-config", + "fuel-core-types", + "hex", "humantime", "lazy_static", "pyroscope", @@ -2852,6 +2855,8 @@ version = "0.21.0-rc.1" dependencies = [ "anyhow", "bech32", + "fuel-core", + "fuel-core-client", "fuel-core-storage", "fuel-core-types", "hex", @@ -2862,6 +2867,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "tokio", "tracing", ] diff --git a/bin/fuel-core/Cargo.toml b/bin/fuel-core/Cargo.toml index dbbd6cf9ed7..08ecf0e76c9 100644 --- a/bin/fuel-core/Cargo.toml +++ b/bin/fuel-core/Cargo.toml @@ -22,6 +22,9 @@ const_format = { version = "0.2", optional = true } dirs = "4.0" dotenvy = { version = "0.15", optional = true } fuel-core = { workspace = true } +fuel-core-chain-config = { workspace = true } +fuel-core-types = { workspace = true } +hex = "0.4" humantime = "2.1" lazy_static = { workspace = true } pyroscope = "0.5" diff --git a/bin/fuel-core/src/cli.rs b/bin/fuel-core/src/cli.rs index bee7d90c1c1..b3ba51c7de9 100644 --- a/bin/fuel-core/src/cli.rs +++ b/bin/fuel-core/src/cli.rs @@ -18,6 +18,7 @@ lazy_static::lazy_static! { pub static ref DEFAULT_DB_PATH: PathBuf = dirs::home_dir().unwrap().join(".fuel").join("db"); } +pub mod fee_contract; pub mod run; pub mod snapshot; @@ -38,6 +39,7 @@ pub struct Opt { pub enum Fuel { Run(run::Command), Snapshot(snapshot::Command), + GenerateFeeContract(fee_contract::Command), } pub const LOG_FILTER: &str = "RUST_LOG"; @@ -117,6 +119,7 @@ pub async fn run_cli() -> anyhow::Result<()> { Ok(opt) => match opt.command { Fuel::Run(command) => run::exec(command).await, Fuel::Snapshot(command) => snapshot::exec(command).await, + Fuel::GenerateFeeContract(command) => fee_contract::exec(command).await, }, Err(e) => { // Prints the error and exits. diff --git a/bin/fuel-core/src/cli/fee_contract.rs b/bin/fuel-core/src/cli/fee_contract.rs new file mode 100644 index 00000000000..741abcb4e50 --- /dev/null +++ b/bin/fuel-core/src/cli/fee_contract.rs @@ -0,0 +1,40 @@ +use clap::Parser; +use fuel_core_chain_config::fee_collection_contract; +use fuel_core_types::fuel_tx::Address; +use std::{ + fs::OpenOptions, + io::Write, + path::PathBuf, +}; + +#[derive(Debug, Parser)] +pub struct Command { + /// Address to withdraw fees to + withdrawal_address: Address, + /// Output file. If not provided, will print hex representation to stdout. + #[clap(short, long)] + output: Option, + /// Overwrite output file if it exists. No effect if `output` is not provided. + #[clap(short, long)] + force: bool, +} + +pub async fn exec(cmd: Command) -> anyhow::Result<()> { + let contract = fee_collection_contract::generate(cmd.withdrawal_address); + + if let Some(output) = cmd.output.as_ref() { + let mut open_opt = OpenOptions::new(); + if cmd.force { + open_opt.create(true).write(true).truncate(true); + } else { + open_opt.create_new(true).write(true); + } + + let mut file = open_opt.open(output)?; + file.write_all(&contract)?; + } else { + println!("{}", hex::encode(contract)); + } + + Ok(()) +} diff --git a/crates/chain-config/Cargo.toml b/crates/chain-config/Cargo.toml index 2f1ba39147c..05f99311a3d 100644 --- a/crates/chain-config/Cargo.toml +++ b/crates/chain-config/Cargo.toml @@ -25,10 +25,13 @@ serde_with = "1.11" tracing = "0.1" [dev-dependencies] +fuel-core = { workspace = true } +fuel-core-client = { workspace = true } fuel-core-types = { workspace = true, default-features = false, features = ["random", "serde"] } insta = { workspace = true } rand = { workspace = true } serde_json = { version = "1.0", features = ["raw_value"] } +tokio = { workspace = true, features = ["full"] } [features] default = ["std"] diff --git a/crates/chain-config/src/fee_collection_contract.rs b/crates/chain-config/src/fee_collection_contract.rs new file mode 100644 index 00000000000..780ebe16174 --- /dev/null +++ b/crates/chain-config/src/fee_collection_contract.rs @@ -0,0 +1,391 @@ +use fuel_core_types::{ + fuel_asm::{ + op, + GTFArgs, + Instruction, + RegId, + }, + fuel_tx::{ + Address, + AssetId, + }, +}; + +/// Generates the bytecode for the fee collection contract. +/// The contract expects `AssetId` and `output_index` as a first elements in `script_data`. +pub fn generate(address: Address) -> Vec { + let start_jump = vec![ + // Jump over the embedded address, which is placed immediately after the jump + op::ji((1 + (Address::LEN / Instruction::SIZE)).try_into().unwrap()), + ]; + + let asset_id_register = 0x10; + let balance_register = 0x11; + let contract_id_register = 0x12; + let output_index_register = 0x13; + let recipient_id_register = 0x14; + let body = vec![ + // Load pointer to AssetId + op::gtf_args(asset_id_register, 0x00, GTFArgs::ScriptData), + // Load output index + op::addi( + output_index_register, + asset_id_register, + u16::try_from(AssetId::LEN).expect("The size is 32"), + ), + op::lw(output_index_register, output_index_register, 0), + // Gets pointer to the contract id + op::move_(contract_id_register, RegId::FP), + // Get the balance of asset ID in the contract + op::bal(balance_register, asset_id_register, contract_id_register), + // If balance == 0, return early + op::jnzf(balance_register, RegId::ZERO, 1), + op::ret(RegId::ONE), + // Pointer to the recipient address + op::addi( + recipient_id_register, + RegId::IS, + Instruction::SIZE.try_into().unwrap(), + ), + // Perform the transfer + op::tro( + recipient_id_register, + output_index_register, + balance_register, + asset_id_register, + ), + // Return + op::ret(RegId::ONE), + ]; + + let mut asm_bytes: Vec = start_jump.into_iter().collect(); + asm_bytes.extend_from_slice(address.as_slice()); // Embed the address + let body: Vec = body.into_iter().collect(); + asm_bytes.extend(body.as_slice()); + + asm_bytes +} + +#[cfg(test)] +#[allow(clippy::cast_possible_truncation)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use super::*; + use crate::SecretKey; + + use rand::{ + rngs::StdRng, + Rng, + SeedableRng, + }; + + use fuel_core::service::{ + Config, + FuelService, + }; + use fuel_core_client::client::{ + types::TransactionStatus, + FuelClient, + }; + use fuel_core_types::{ + fuel_asm::GTFArgs, + fuel_tx::{ + Cacheable, + Finalizable, + Input, + Output, + TransactionBuilder, + Witness, + }, + fuel_types::{ + canonical::Serialize, + AssetId, + BlockHeight, + ChainId, + ContractId, + Salt, + }, + }; + + struct TestContext { + address: Address, + contract_id: ContractId, + _node: FuelService, + client: FuelClient, + } + + async fn setup(rng: &mut StdRng) -> TestContext { + // Make contract that coinbase fees are collected into + let address: Address = rng.gen(); + let salt: Salt = rng.gen(); + let contract = generate(address); + let witness: Witness = contract.into(); + let mut create_tx = TransactionBuilder::create(witness.clone(), salt, vec![]) + .add_random_fee_input() + .finalize(); + create_tx + .precompute(&ChainId::default()) + .expect("tx should be valid"); + let contract_id = create_tx.metadata().as_ref().unwrap().contract_id; + + // Start up a node + let mut config = Config::local_node(); + config.debug = true; + config.block_producer.coinbase_recipient = Some(contract_id); + let node = FuelService::new_node(config).await.unwrap(); + let client = FuelClient::from(node.bound_address); + + // Submit contract creation tx + let tx_status = client + .submit_and_await_commit(&create_tx.into()) + .await + .unwrap(); + assert!(matches!(tx_status, TransactionStatus::Success { .. })); + let bh = client.produce_blocks(1, None).await.unwrap(); + assert_eq!(bh, BlockHeight::new(2)); + + // No fees should have been collected yet + let contract_balance = + client.contract_balance(&(contract_id), None).await.unwrap(); + assert_eq!(contract_balance, 0); + + TestContext { + address, + contract_id, + _node: node, + client, + } + } + + /// This makes a block with a single transaction that has a fee, + /// so that the coinbase fee is collected into the contract + async fn make_block_with_fee(rng: &mut StdRng, ctx: &TestContext) { + let old_balance = ctx + .client + .contract_balance(&ctx.contract_id, None) + .await + .unwrap(); + + // Run a script that does nothing, but will cause fee collection + let tx = TransactionBuilder::script( + [op::ret(RegId::ONE)].into_iter().collect(), + vec![], + ) + .add_unsigned_coin_input( + SecretKey::random(rng), + rng.gen(), + 1000, + Default::default(), + Default::default(), + Default::default(), + ) + .gas_price(1) + .script_gas_limit(1_000_000) + .finalize_as_transaction(); + let tx_status = ctx.client.submit_and_await_commit(&tx).await.unwrap(); + assert!(matches!(tx_status, TransactionStatus::Success { .. })); + + // Now the coinbase fee should be reflected in the contract balance + let new_balance = ctx + .client + .contract_balance(&ctx.contract_id, None) + .await + .unwrap(); + assert!(new_balance > old_balance); + } + + async fn collect_fees(ctx: &TestContext) { + let TestContext { + client, + contract_id, + .. + } = ctx; + + let asset_id = AssetId::BASE; + let output_index = 1u64; + let call_struct_register = 0x10; + // Now call the fee collection contract to withdraw the fees + let script = vec![ + // Point to the call structure + op::gtf_args(call_struct_register, 0x00, GTFArgs::ScriptData), + op::addi( + call_struct_register, + call_struct_register, + (asset_id.size() + output_index.size()) as u16, + ), + op::call(call_struct_register, RegId::ZERO, RegId::ZERO, RegId::CGAS), + op::ret(RegId::ONE), + ]; + + let tx = TransactionBuilder::script( + script.into_iter().collect(),asset_id.to_bytes().into_iter() + .chain(output_index.to_bytes().into_iter()) + .chain(contract_id + .to_bytes().into_iter()) + .chain(0u64.to_bytes().into_iter()) + .chain(0u64.to_bytes().into_iter()) + .collect(), + ) + .add_random_fee_input() // No coinbase fee for this block + .gas_price(0) + .script_gas_limit(1_000_000) + .add_input(Input::contract( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + *contract_id, + )) + .add_output(Output::contract(1, Default::default(), Default::default())) + .add_output(Output::variable( + Default::default(), + Default::default(), + Default::default(), + )) + .finalize_as_transaction(); + + let tx_status = client.submit_and_await_commit(&tx).await.unwrap(); + assert!( + matches!(tx_status, TransactionStatus::Success { .. }), + "{tx_status:?}" + ); + } + + #[tokio::test] + async fn happy_path() { + let rng = &mut StdRng::seed_from_u64(0); + + let ctx = setup(rng).await; + + for _ in 0..10 { + make_block_with_fee(rng, &ctx).await; + } + + // When + // Before withdrawal, the recipient's balance should be zero, + // and the contract balance should be non-zero. + let contract_balance_before_collect = ctx + .client + .contract_balance(&ctx.contract_id, None) + .await + .unwrap(); + assert_ne!(contract_balance_before_collect, 0); + assert_eq!(ctx.client.balance(&ctx.address, None).await.unwrap(), 0); + + // When + collect_fees(&ctx).await; + + // Then + + // Make sure that the full balance was been withdrawn + let contract_balance_after_collect = ctx + .client + .contract_balance(&ctx.contract_id, None) + .await + .unwrap(); + assert_eq!(contract_balance_after_collect, 0); + + // Make sure that the full balance was been withdrawn + assert_eq!( + ctx.client.balance(&ctx.address, None).await.unwrap(), + contract_balance_before_collect + ); + } + + /// Attempts fee collection when no balance has accumulated yet + #[tokio::test] + async fn no_fees_collected_yet() { + let rng = &mut StdRng::seed_from_u64(0); + + let ctx = setup(rng).await; + + // Given + let contract_balance_before_collect = ctx + .client + .contract_balance(&ctx.contract_id, None) + .await + .unwrap(); + assert_eq!(contract_balance_before_collect, 0); + assert_eq!(ctx.client.balance(&ctx.address, None).await.unwrap(), 0); + + // When + collect_fees(&ctx).await; + + // Then + + // Make sure that the balance is still zero + let contract_balance = ctx + .client + .contract_balance(&ctx.contract_id, None) + .await + .unwrap(); + assert_eq!(contract_balance, 0); + + // There were no coins to withdraw + assert_eq!(ctx.client.balance(&ctx.address, None).await.unwrap(), 0); + } + + #[tokio::test] + async fn missing_variable_output() { + let rng = &mut StdRng::seed_from_u64(0); + + let ctx = setup(rng).await; + make_block_with_fee(rng, &ctx).await; + + let asset_id = AssetId::BASE; + let output_index = 1u64; + let call_struct_register = 0x10; + + // Now call the fee collection contract to withdraw the fees, + // but unlike in the happy path, we don't add the variable output to the transaction. + let script = vec![ + // Point to the call structure + op::gtf_args(call_struct_register, 0x00, GTFArgs::ScriptData), + op::addi( + call_struct_register, + call_struct_register, + (asset_id.size() + output_index.size()) as u16, + ), + op::call(call_struct_register, RegId::ZERO, RegId::ZERO, RegId::CGAS), + op::ret(RegId::ONE), + ]; + let tx = TransactionBuilder::script( + script.into_iter().collect(), + asset_id.to_bytes().into_iter() + .chain(output_index.to_bytes().into_iter()) + .chain(ctx.contract_id + .to_bytes().into_iter()) + .chain(0u64.to_bytes().into_iter()) + .chain(0u64.to_bytes().into_iter()) + .collect(), + ) + .add_random_fee_input() // No coinbase fee for this block + .gas_price(0) + .script_gas_limit(1_000_000) + .add_input(Input::contract( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + ctx.contract_id, + )) + .add_output(Output::contract(1, Default::default(), Default::default())) + .finalize_as_transaction(); + + let tx_status = ctx.client.submit_and_await_commit(&tx).await.unwrap(); + let TransactionStatus::Failure { reason, .. } = tx_status else { + panic!("Expected failure"); + }; + assert_eq!(reason, "OutputNotFound"); + + // Make sure that nothing was withdrawn + let contract_balance = ctx + .client + .contract_balance(&ctx.contract_id, None) + .await + .unwrap(); + assert_eq!(contract_balance, 1); + let asset_balance = ctx.client.balance(&ctx.address, None).await.unwrap(); + assert_eq!(asset_balance, 0); + } +} diff --git a/crates/chain-config/src/lib.rs b/crates/chain-config/src/lib.rs index 5cd1755609d..13d413dfc2d 100644 --- a/crates/chain-config/src/lib.rs +++ b/crates/chain-config/src/lib.rs @@ -4,6 +4,7 @@ #![deny(warnings)] pub mod config; +pub mod fee_collection_contract; mod genesis; mod serialization;