diff --git a/.env b/.env index 300c02280..5a09a01e5 100644 --- a/.env +++ b/.env @@ -40,8 +40,6 @@ ESPRESSO_SEQUENCER_L1_PROVIDER=http://demo-l1-network:${ESPRESSO_SEQUENCER_L1_PO # Only allow 1 block to be processed for events at a time, simulating a very bad L1 provider. ESPRESSO_SEQUENCER_L1_EVENTS_MAX_BLOCK_RANGE=1 ESPRESSO_SEQUENCER_ETH_MNEMONIC="test test test test test test test test test test test junk" -# The first account is the permission less builder, the last are sequencer0 to 4 -ESPRESSO_SEQUENCER_PREFUNDED_BUILDER_ACCOUNTS=0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f ESPRESSO_COMMITMENT_TASK_PORT=30010 ESPRESSO_SEQUENCER0_DB_PORT=5432 ESPRESSO_SEQUENCER1_DB_PORT=5433 @@ -55,8 +53,9 @@ ESPRESSO_BUILDER_ETH_ACCOUNT_INDEX=8 ESPRESSO_DEPLOYER_ACCOUNT_INDEX=9 # Contracts -ESPRESSO_SEQUENCER_HOTSHOT_ADDRESS=0x700b6a60ce7eaaea56f065753d8dcb9653dbad35 -ESPRESSO_SEQUENCER_LIGHT_CLIENT_PROXY_ADDRESS=0xe1aa25618fa0c7a1cfdab5d6b456af611873b629 +ESPRESSO_SEQUENCER_HOTSHOT_ADDRESS=0xb19b36b1456e65e3a6d514d3f715f204bd59f431 +ESPRESSO_SEQUENCER_FEE_CONTRACT_PROXY_ADDRESS=0xa15bb66138824a1c7167f5e85b957d04dd34e468 +ESPRESSO_SEQUENCER_LIGHT_CLIENT_PROXY_ADDRESS=0xe1da8919f262ee86f9be05059c9280142cf23f48 ESPRESSO_SEQUENCER_LIGHTCLIENT_ADDRESS=$ESPRESSO_SEQUENCER_LIGHT_CLIENT_PROXY_ADDRESS # Example sequencer demo private keys diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8191c0344..8d336fa77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,6 +73,7 @@ jobs: target/release/permissionless-builder target/release/nasty-client target/release/pub-key + target/release/bridge build-arm: runs-on: buildjet-4vcpu-ubuntu-2204-arm @@ -117,6 +118,7 @@ jobs: target/release/permissionless-builder target/release/nasty-client target/release/pub-key + target/release/bridge build-dockers: runs-on: ubuntu-latest @@ -134,6 +136,7 @@ jobs: deploy-tag: ${{ steps.deploy.outputs.tags }} builder-tag: ${{ steps.builder.outputs.tags }} nasty-client-tag: ${{ steps.nasty-client.outputs.tags }} + bridge-tag: ${{ steps.bridge.outputs.tags }} steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -235,6 +238,12 @@ jobs: with: images: ghcr.io/espressosystems/espresso-sequencer/nasty-client + - name: Generate bridge metadata + uses: docker/metadata-action@v5 + id: bridge + with: + images: ghcr.io/espressosystems/espresso-sequencer/bridge + - name: Build and push sequencer docker uses: docker/build-push-action@v5 with: @@ -355,6 +364,16 @@ jobs: tags: ${{ steps.nasty-client.outputs.tags }} labels: ${{ steps.nasty-client.outputs.labels }} + - name: Build and push bridge docker + uses: docker/build-push-action@v5 + with: + context: ./ + file: ./docker/bridge.Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.bridge.outputs.tags }} + labels: ${{ steps.bridge.outputs.labels }} + test-demo: if: ${{ github.event_name != 'pull_request' }} runs-on: ubuntu-latest @@ -382,6 +401,7 @@ jobs: docker pull ${{ needs.build-dockers.outputs.deploy-tag }} docker pull ${{ needs.build-dockers.outputs.builder-tag }} docker pull ${{ needs.build-dockers.outputs.nasty-client-tag }} + docker pull ${{ needs.build-dockers.outputs.bridge-tag }} - name: Tag new docker images run: | docker tag ${{ needs.build-dockers.outputs.sequencer-tag }} ghcr.io/espressosystems/espresso-sequencer/sequencer:main @@ -396,6 +416,7 @@ jobs: docker tag ${{ needs.build-dockers.outputs.deploy-tag }} ghcr.io/espressosystems/espresso-sequencer/deploy:main docker tag ${{ needs.build-dockers.outputs.builder-tag }} ghcr.io/espressosystems/espresso-sequencer/builder:main docker tag ${{ needs.build-dockers.outputs.nasty-client-tag }} ghcr.io/espressosystems/espresso-sequencer/nasty-client:main + docker tag ${{ needs.build-dockers.outputs.bridge-tag }} ghcr.io/espressosystems/espresso-sequencer/bridge:main - name: Test docker demo run: | diff --git a/.github/workflows/build_static.yml b/.github/workflows/build_static.yml index e0727ca09..bae539698 100644 --- a/.github/workflows/build_static.yml +++ b/.github/workflows/build_static.yml @@ -89,6 +89,7 @@ jobs: ${{ env.CARGO_TARGET_DIR }}/${{ env.TARGET_TRIPLET }}/release/deploy ${{ env.CARGO_TARGET_DIR }}/${{ env.TARGET_TRIPLET }}/release/keygen ${{ env.CARGO_TARGET_DIR }}/${{ env.TARGET_TRIPLET }}/release/pub-key + ${{ env.CARGO_TARGET_DIR }}/${{ env.TARGET_TRIPLET }}/release/bridge static-dockers: runs-on: ubuntu-latest @@ -192,6 +193,13 @@ jobs: images: ghcr.io/espressosystems/espresso-sequencer/deploy flavor: suffix=musl + - name: Generate bridge metadata + uses: docker/metadata-action@v5 + id: bridge + with: + images: ghcr.io/espressosystems/espresso-sequencer/bridge + flavor: suffix=musl + - name: Build and push sequencer docker uses: docker/build-push-action@v5 with: @@ -291,3 +299,13 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.deploy.outputs.tags }} labels: ${{ steps.deploy.outputs.labels }} + + - name: Build and push bridge docker + uses: docker/build-push-action@v5 + with: + context: ./ + file: ./docker/bridge.Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.bridge.outputs.tags }} + labels: ${{ steps.bridge.outputs.labels }} diff --git a/builder/src/bin/permissionless-builder.rs b/builder/src/bin/permissionless-builder.rs index 316d65c4e..19c4a1e30 100644 --- a/builder/src/bin/permissionless-builder.rs +++ b/builder/src/bin/permissionless-builder.rs @@ -3,10 +3,10 @@ use builder::non_permissioned::{build_instance_state, BuilderConfig}; use clap::Parser; use cld::ClDuration; use es_version::SEQUENCER_VERSION; +use ethers::types::U256; use hotshot_types::data::ViewNumber; use hotshot_types::traits::node_implementation::ConsensusTime; -use sequencer::L1Params; -use sequencer::{eth_signature_key::EthKeyPair, options::parse_size}; +use sequencer::{eth_signature_key::EthKeyPair, options::parse_size, ChainConfig, L1Params}; use snafu::Snafu; use std::num::NonZeroUsize; use std::{str::FromStr, time::Duration}; @@ -42,9 +42,17 @@ struct NonPermissionedBuilderOptions { #[clap(long, env = "ESPRESSO_SEQUENCER_STATE_PEERS", value_delimiter = ',')] state_peers: Vec, + /// Unique identifier for this instance of the sequencer network. + #[clap(long, env = "ESPRESSO_SEQUENCER_CHAIN_ID", default_value = "0")] + chain_id: u64, + /// Maximum size in bytes of a block #[clap(long, env = "ESPRESSO_SEQUENCER_MAX_BLOCK_SIZE", value_parser = parse_size)] - pub max_block_size: u64, + max_block_size: u64, + + /// Minimum fee in WEI per byte of payload + #[clap(long, env = "ESPRESSO_SEQUENCER_BASE_FEE")] + base_fee: U256, /// Port to run the builder server on. #[clap(short, long, env = "ESPRESSO_BUILDER_SERVER_PORT")] @@ -79,10 +87,6 @@ struct NonPermissionedBuilderOptions { default_value = "15" )] buffer_view_num_count: usize, - - /// Base Fee for a block - #[clap(long, env = "ESPRESSO_BUILDER_BLOCK_BASE_FEE", default_value = "0")] - base_fee: u64, } #[derive(Clone, Debug, Snafu)] @@ -118,13 +122,14 @@ async fn main() -> anyhow::Result<()> { let builder_server_url: Url = format!("http://0.0.0.0:{}", opt.port).parse().unwrap(); - let instance_state = build_instance_state( - l1_params, - opt.state_peers, - opt.max_block_size, - sequencer_version, - ) - .unwrap(); + let chain_config = ChainConfig { + chain_id: opt.chain_id.into(), + max_block_size: opt.max_block_size, + base_fee: opt.base_fee.into(), + ..Default::default() + }; + let instance_state = + build_instance_state(l1_params, opt.state_peers, chain_config, sequencer_version).unwrap(); let api_response_timeout_duration = opt.max_api_timeout_duration; @@ -144,7 +149,6 @@ async fn main() -> anyhow::Result<()> { api_response_timeout_duration, buffer_view_num_count, txn_timeout_duration, - opt.base_fee, ) .await; diff --git a/builder/src/lib.rs b/builder/src/lib.rs index 0c9e77468..980653d93 100644 --- a/builder/src/lib.rs +++ b/builder/src/lib.rs @@ -391,11 +391,7 @@ pub mod testing { let node_state = NodeState::new( i as u64, ChainConfig::default(), - L1Client::new( - self.anvil.endpoint().parse().unwrap(), - Address::default(), - 1, - ), + L1Client::new(self.anvil.endpoint().parse().unwrap(), 1), MockStateCatchup::default(), ) .with_genesis(ValidatedState::default()); @@ -535,7 +531,6 @@ pub mod testing { ChainConfig::default(), L1Client::new( hotshot_test_config.get_anvil().endpoint().parse().unwrap(), - Address::default(), 1, ), MockStateCatchup::default(), @@ -565,7 +560,6 @@ pub mod testing { Duration::from_millis(2000), 15, Duration::from_millis(500), - 0, ) .await .unwrap(); @@ -601,7 +595,6 @@ pub mod testing { ChainConfig::default(), L1Client::new( hotshot_test_config.get_anvil().endpoint().parse().unwrap(), - Address::default(), 1, ), MockStateCatchup::default(), @@ -630,7 +623,6 @@ pub mod testing { Duration::from_millis(2000), 15, Duration::from_millis(500), - 0, ) .await .unwrap(); diff --git a/builder/src/non_permissioned.rs b/builder/src/non_permissioned.rs index e8077f33e..391840511 100644 --- a/builder/src/non_permissioned.rs +++ b/builder/src/non_permissioned.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use async_broadcast::{ broadcast, Receiver as BroadcastReceiver, RecvError, Sender as BroadcastSender, TryRecvError, }; @@ -59,17 +60,13 @@ pub struct BuilderConfig { pub fn build_instance_state( l1_params: L1Params, state_peers: Vec, - max_block_size: u64, + chain_config: ChainConfig, _: Ver, ) -> anyhow::Result { - let l1_client = L1Client::new( - l1_params.url, - Address::default(), - l1_params.events_max_block_range, - ); + let l1_client = L1Client::new(l1_params.url, l1_params.events_max_block_range); let instance_state = NodeState::new( u64::MAX, // dummy node ID, only used for debugging - ChainConfig::new(0, max_block_size, 0), + chain_config, l1_client, Arc::new(StatePeers::::from_urls(state_peers)), ); @@ -89,8 +86,17 @@ impl BuilderConfig { max_api_timeout_duration: Duration, buffered_view_num_count: usize, maximize_txns_count_timeout_duration: Duration, - base_fee: u64, ) -> anyhow::Result { + tracing::info!( + address = %builder_key_pair.fee_account(), + ?bootstrapped_view, + %channel_capacity, + ?max_api_timeout_duration, + buffered_view_num_count, + ?maximize_txns_count_timeout_duration, + "initializing builder", + ); + // tx channel let (tx_sender, tx_receiver) = broadcast::>(channel_capacity.get()); @@ -113,7 +119,6 @@ impl BuilderConfig { let builder_commitment = genesis_payload.builder_commitment(&genesis_ns_table); let vid_commitment = { - // TODO we should not need to collect payload bytes just to compute vid_commitment let payload_bytes = genesis_payload .encode() .expect("unable to encode genesis payload"); @@ -151,7 +156,11 @@ impl BuilderConfig { bootstrapped_view, buffered_view_num_count as u64, maximize_txns_count_timeout_duration, - base_fee, + instance_state + .chain_config() + .base_fee + .as_u64() + .context("the base fee exceeds the maximum amount that a builder can pay (defined by u64::MAX)")?, Arc::new(instance_state), ); diff --git a/builder/src/permissioned.rs b/builder/src/permissioned.rs index 2e3d160a3..c36793e37 100644 --- a/builder/src/permissioned.rs +++ b/builder/src/permissioned.rs @@ -247,15 +247,14 @@ pub async fn init_node::from_urls(network_params.state_peers)), ); @@ -289,7 +288,6 @@ pub async fn init_node anyhow::Result { // tx channel let (tx_sender, tx_receiver) = broadcast::>(channel_capacity.get()); @@ -447,7 +444,11 @@ impl /dev/null; do - elapsed=$((SECONDS - start)) - if [[ $elapsed -gt $timeout ]]; then - echo "Timeout waiting for $what @ $url" - exit 1 - fi - echo "Waiting for $what @ $url, $elapsed of $timeout seconds elapsed" - sleep 1 - done - echo "Endpoint $what @ $url is Ok, after $elapsed seconds" + timeout=$1 + what=$2 + url=$3 + start=$SECONDS + elapsed=0 + echo "Checking if $what @ $url is available, timeout in $timeout seconds" + while ! curl -sL --fail "$url" > /dev/null; do + elapsed=$((SECONDS - start)) + if [[ $elapsed -gt $timeout ]]; then + echo "Timeout waiting for $what @ $url" + exit 1 + fi + echo "Waiting for $what @ $url, $elapsed of $timeout seconds elapsed" + sleep 1 + done + echo "Endpoint $what @ $url is Ok, after $elapsed seconds" +} + +# usage: get_balance
+function get_balance() { + if which bridge > /dev/null ; then + # If the bridge program is built locally, use it. + ( + unset MNEMONIC + RUST_LOG=off bridge balance -e $SEQUENCER_API -a $1 -b $2 + ) + else + # Otherwise, use Docker. + docker run -e RUST_LOG=off ghcr.io/espressosystems/espresso-sequencer/bridge:main \ + bridge balance -e $SEQUENCER_API -a $1 -b $2 + fi } # Wait for the load generator to start. @@ -36,10 +51,16 @@ wait_for 300 "demo load generator" "$LOAD_GENERATOR/healthcheck" # both increased. block_height=`curl -sL $SEQUENCER_API/node/block-height` num_tx=`curl -sL $SEQUENCER_API/node/transactions/count` +# Get the balance of the builder and fee recipient accounts. The former should decrease over time +# while the latter should increase. +builder_balance=`get_balance 0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f $block_height` +recipient_balance=`get_balance 0x0000000000000000000000000000000000000000 $block_height` echo "Initial state:" echo " block_height: $block_height" echo " transactions: $num_tx" +echo " builder_balance: $builder_balance" +echo " recipient_balance: $recipient_balance" # Bash magic to get the current time in seconds since start of this shell START=$SECONDS @@ -47,31 +68,42 @@ START=$SECONDS # Check blocks and transactions are increasing # Every second until timeout after $TIMEOUT seconds while true; do - new_block_height=`curl -sL $SEQUENCER_API/node/block-height` - new_num_tx=`curl -sL $SEQUENCER_API/node/transactions/count` - if [[ $new_block_height -gt $block_height && $new_num_tx -gt $num_tx ]]; then - echo "Final state:" - echo " block_height: $new_block_height" - echo " transactions: $new_num_tx" - echo "Block height and transaction count are increasing. Great!" - break - fi - sleep 1 - if [[ $((SECONDS - START)) -gt $SEQUENCER_BLOCKS_TIMEOUT ]]; then - echo "Timeout waiting for block height and transaction count to increase" - echo "Final state:" - echo " block_height: $new_block_height" - echo " transactions: $new_num_tx" - - if ! [[ $new_block_height -gt $block_height ]]; then - echo "Block height is not increasing!" - fi - if ! [[ $new_num_tx -gt $num_tx ]]; then - echo "Transaction count is not increasing!" - fi - - exit 1 - fi + new_block_height=`curl -sL $SEQUENCER_API/node/block-height` + new_num_tx=`curl -sL $SEQUENCER_API/node/transactions/count` + if [[ $new_block_height -gt $block_height && $new_num_tx -gt $num_tx ]]; then + new_builder_balance=`get_balance 0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f $new_block_height` + new_recipient_balance=`get_balance 0x0000000000000000000000000000000000000000 $new_block_height` + + echo "Final state:" + echo " block_height: $new_block_height" + echo " transactions: $new_num_tx" + echo " builder_balance: $new_builder_balance" + echo " recipient_balance: $new_recipient_balance" + echo "Block height and transaction count are increasing. Great!" + + if [[ $((builder_balance + recipient_balance)) != $((new_builder_balance + new_recipient_balance)) ]]; then + echo "Balance not conserved!" + exit 1 + fi + + break + fi + sleep 1 + if [[ $((SECONDS - START)) -gt $SEQUENCER_BLOCKS_TIMEOUT ]]; then + echo "Timeout waiting for block height and transaction count to increase" + echo "Final state:" + echo " block_height: $new_block_height" + echo " transactions: $new_num_tx" + + if ! [[ $new_block_height -gt $block_height ]]; then + echo "Block height is not increasing!" + fi + if ! [[ $new_num_tx -gt $num_tx ]]; then + echo "Transaction count is not increasing!" + fi + + exit 1 + fi done diff --git a/sequencer/src/api.rs b/sequencer/src/api.rs index 6ea94e34c..8a35929b4 100644 --- a/sequencer/src/api.rs +++ b/sequencer/src/api.rs @@ -327,7 +327,6 @@ mod test_helpers { &*metrics, STAKE_TABLE_CAPACITY_FOR_TEST, SEQUENCER_VERSION, - true, ) .await } @@ -346,7 +345,6 @@ mod test_helpers { &NoMetrics, STAKE_TABLE_CAPACITY_FOR_TEST, SEQUENCER_VERSION, - true, ) .await } @@ -990,7 +988,6 @@ mod test { &NoMetrics, test_helpers::STAKE_TABLE_CAPACITY_FOR_TEST, SEQUENCER_VERSION, - true, ) .await; let mut events = node.get_event_stream(); diff --git a/sequencer/src/bin/bridge.rs b/sequencer/src/bin/bridge.rs new file mode 100644 index 000000000..3f510d5e7 --- /dev/null +++ b/sequencer/src/bin/bridge.rs @@ -0,0 +1,343 @@ +use anyhow::{bail, ensure, Context}; +use async_compatibility_layer::logging::{setup_backtrace, setup_logging}; +use async_std::{sync::Arc, task::sleep}; +use clap::Parser; +use contract_bindings::fee_contract::FeeContract; +use es_version::SequencerVersion; +use ethers::{ + middleware::{Middleware, SignerMiddleware}, + providers::Provider, + types::{Address, BlockId, U256}, +}; +use futures::stream::StreamExt; +use jf_merkle_tree::{ + prelude::{MerkleProof, Sha3Node}, + MerkleTreeScheme, +}; +use sequencer::{ + eth_signature_key::EthKeyPair, + state::{FeeAccount, FeeAmount, FeeMerkleTree}, + Header, +}; +use std::time::Duration; +use surf_disco::{error::ClientError, Url}; + +type EspressoClient = surf_disco::Client; + +type FeeMerkleProof = MerkleProof; + +/// Command-line utility for working with the Espresso bridge. +#[derive(Debug, Parser)] +enum Command { + Deposit(Deposit), + Balance(Balance), + L1Balance(L1Balance), +} + +/// Deposit ETH from the L1 into Espresso. +#[derive(Debug, Parser)] +struct Deposit { + /// L1 JSON-RPC provider. + #[clap(short, long, env = "L1_PROVIDER")] + rpc_url: Url, + + /// Espresso query service provider. + /// + /// This must point to an Espresso node running the /availability, /node and Merklized state + /// (/fee-state and /block-state) APIs. + #[clap(short, long, env = "ESPRESSO_PROVIDER")] + espresso_provider: Url, + + /// The address of the Espresso fee contract on the L1. + #[clap(short, long, env = "CONTRACT_ADDRESS")] + contract_address: Address, + + /// Mnemonic to generate the account from which to deposit. + #[clap(short, long, env = "MNEMONIC")] + mnemonic: String, + + /// Account index when deriving an account from MNEMONIC. + #[clap(short = 'i', long, env = "ACCOUNT_INDEX", default_value = "0")] + account_index: u32, + + /// Amount of WEI to deposit. + // Note: we use u64 because U256 parses in hex, which is annoying. We can easily convert to U256 + // after parsing. + #[clap(short, long, env = "AMOUNT")] + amount: u64, + + /// Number of confirmations to wait for before considering an L1 transaction mined. + #[clap(long, env = "CONFIRMATIONS", default_value = "6")] + confirmations: usize, +} + +/// Check the balance (in ETH) of an Espresso account. +#[derive(Debug, Parser)] +struct Balance { + /// Espresso query service provider. + /// + /// This must point to an Espresso node running the node and Merklized state APIs. + #[clap(short, long, env = "ESPRESSO_PROVIDER")] + espresso_provider: Url, + + /// Account to check. + #[clap(short, long, env = "ADDRESS", required_unless_present = "mnemonic")] + address: Option
, + + /// Mnemonic to generate the account to check. + #[clap(short, long, env = "MNEMONIC", conflicts_with = "address")] + mnemonic: Option, + + /// Account index when deriving an account from MNEMONIC. + #[clap( + short = 'i', + long, + env = "ACCOUNT_INDEX", + default_value = "0", + conflicts_with = "address" + )] + account_index: u32, + + /// Espresso block number at which to check (default: latest). + #[clap(short, long, env = "BLOCK")] + block: Option, +} + +/// Check the balance (in ETH) of an L1 account. +#[derive(Debug, Parser)] +struct L1Balance { + /// L1 JSON-RPC provider. + #[clap(short, long, env = "L1_PROVIDER")] + rpc_url: Url, + + /// Account to check. + #[clap(short, long, env = "ADDRESS", required_unless_present = "mnemonic")] + address: Option
, + + /// Mnemonic to generate the account to check. + #[clap(short, long, env = "MNEMONIC", conflicts_with = "address")] + mnemonic: Option, + + /// Account index when deriving an account from MNEMONIC. + #[clap( + short = 'i', + long, + env = "ACCOUNT_INDEX", + default_value = "0", + conflicts_with = "address" + )] + account_index: u32, + + /// L1 block number at which to check (default: latest). + #[clap(short, long, env = "BLOCK")] + block: Option, +} + +async fn deposit(opt: Deposit) -> anyhow::Result<()> { + // Derive the account to deposit from. + let key_pair = EthKeyPair::from_mnemonic(opt.mnemonic, opt.account_index)?; + + // Connect to L1. + let rpc = Provider::try_from(opt.rpc_url.to_string())?; + let signer = key_pair.signer(); + let l1 = Arc::new(SignerMiddleware::new_with_provider_chain(rpc, signer).await?); + let contract = FeeContract::new(opt.contract_address, l1.clone()); + + // Connect to Espresso. + let espresso = EspressoClient::new(opt.espresso_provider); + + // Validate deposit. + let amount = U256::from(opt.amount); + let min_deposit = contract.min_deposit_amount().call().await?; + let max_deposit = contract.max_deposit_amount().call().await?; + ensure!( + amount >= min_deposit, + "amount is too small (minimum deposit: {min_deposit})", + ); + ensure!( + amount <= max_deposit, + "amount is too large (maximum deposit: {max_deposit})", + ); + + // Record the initial balance on Espresso. + let initial_balance = get_espresso_balance(&espresso, l1.address(), None).await?; + tracing::debug!(%initial_balance, "initial balance"); + + // Send the deposit transaction. + tracing::info!(address = %l1.address(), %amount, "sending deposit transaction"); + let call = contract.deposit(l1.address()).value(amount); + let tx = call.send().await.context("sending deposit transaction")?; + tracing::info!(hash = %tx.tx_hash(), "deposit transaction sent to L1"); + + // Wait for the transaction to finalize on L1. + let receipt = tx + .confirmations(opt.confirmations) + .await + .context("waiting for deposit transaction")? + .context("deposit transaction not mined")?; + let l1_block = receipt + .block_number + .context("deposit transaction not mined")? + .as_u64(); + ensure!( + receipt.status == Some(1.into()), + "deposit transaction reverted" + ); + tracing::info!(l1_block, "deposit mined on L1"); + + // Wait for Espresso to catch up to the L1. + let espresso_height = espresso + .get::("node/block-height") + .send() + .await + .context("getting Espresso block height")?; + let mut headers = espresso + .socket(&format!("availability/stream/headers/{espresso_height}")) + .subscribe() + .await + .context("subscribing to Espresso headers")?; + let espresso_block = loop { + let header: Header = match headers.next().await.context("header stream ended")? { + Ok(header) => header, + Err(err) => { + tracing::warn!("error in header stream: {err:#}"); + continue; + } + }; + let Some(l1_finalized) = header.l1_finalized else { + continue; + }; + if l1_finalized.number >= l1_block { + tracing::info!(block = header.height, "deposit finalized on Espresso"); + break header.height; + } else { + tracing::debug!( + block = header.height, + l1_block, + ?l1_finalized, + "waiting for deposit on Espresso" + ) + } + }; + + // Confirm that the Espresso balance has increased. + let final_balance = get_espresso_balance(&espresso, l1.address(), Some(espresso_block)).await?; + if final_balance >= initial_balance + amount.into() { + tracing::info!(%final_balance, "deposit successful"); + } else { + // The balance didn't increase as much as expected. This doesn't necessarily mean the + // deposit failed: there could have been a race condition where the balance on Espresso was + // altered by some other operation at the same time, but we should at least let the user + // know about it. + tracing::warn!(%initial_balance, %final_balance, "Espresso balance did not increase as expected"); + } + + Ok(()) +} + +async fn balance(opt: Balance) -> anyhow::Result<()> { + // Derive the address to look up. + let address = if let Some(address) = opt.address { + address + } else if let Some(mnemonic) = opt.mnemonic { + EthKeyPair::from_mnemonic(mnemonic, opt.account_index)?.address() + } else { + bail!("address or mnemonic must be provided"); + }; + + let espresso = EspressoClient::new(opt.espresso_provider); + let balance = get_espresso_balance(&espresso, address, opt.block).await?; + + // Output the balance on regular standard out, rather than as a log message, to make scripting + // easier. + println!("{balance}"); + + Ok(()) +} + +async fn l1_balance(opt: L1Balance) -> anyhow::Result<()> { + // Derive the address to look up. + let address = if let Some(address) = opt.address { + address + } else if let Some(mnemonic) = opt.mnemonic { + EthKeyPair::from_mnemonic(mnemonic, opt.account_index)?.address() + } else { + bail!("address or mnemonic must be provided"); + }; + + let l1 = Provider::try_from(opt.rpc_url.to_string())?; + + let block = opt.block.map(BlockId::from); + tracing::debug!(%address, ?block, "fetching L1 balance"); + let balance = l1 + .get_balance(address, block) + .await + .context("getting account balance")?; + + // Output the balance on regular standard out, rather than as a log message, to make scripting + // easier. + println!("{balance}"); + + Ok(()) +} + +async fn get_espresso_balance( + espresso: &EspressoClient, + address: Address, + block: Option, +) -> anyhow::Result { + // Get the block height to query at, defaulting to the latest block. + let block = if let Some(block) = block { + block + } else { + espresso + .get::("node/block-height") + .send() + .await + .context("getting block height")? + - 1 + }; + + // Download the Merkle path for this fee account at the specified block height. Transient errors + // are possible (for example, if we are fetching from the latest block, the block height might + // get incremented slightly before the state becomes available) so retry a few times. + let mut retry = 0; + let max_retries = 5; + let proof = loop { + tracing::debug!(%address, block, retry, "fetching Espresso balance"); + match espresso + .get::(&format!("fee-state/{block}/{address:#x}")) + .send() + .await + { + Ok(proof) => break proof, + Err(err) => { + tracing::warn!("error getting account balance: {err:#}"); + retry += 1; + + if retry == max_retries { + return Err(err).context("getting account balance"); + } else { + sleep(Duration::from_secs(1)).await; + } + } + } + }; + + // If the element in the Merkle path is missing -- there is no account with this address -- the + // balance is defined to be 0. + let balance = proof.elem().copied().unwrap_or(0.into()); + Ok(balance) +} + +#[async_std::main] +async fn main() -> anyhow::Result<()> { + setup_logging(); + setup_backtrace(); + + match Command::parse() { + Command::Deposit(opt) => deposit(opt).await, + Command::Balance(opt) => balance(opt).await, + Command::L1Balance(opt) => l1_balance(opt).await, + } +} diff --git a/sequencer/src/bin/deploy.rs b/sequencer/src/bin/deploy.rs index 022265034..6f76168b1 100644 --- a/sequencer/src/bin/deploy.rs +++ b/sequencer/src/bin/deploy.rs @@ -1,9 +1,10 @@ use anyhow::{ensure, Context}; use async_compatibility_layer::logging::{setup_backtrace, setup_logging}; use async_std::sync::Arc; -use clap::Parser; +use clap::{Parser, ValueEnum}; use contract_bindings::{ - erc1967_proxy::ERC1967Proxy, hot_shot::HotShot, light_client::LightClient, + erc1967_proxy::ERC1967Proxy, fee_contract::FeeContract, hot_shot::HotShot, + light_client::LightClient, }; use ethers::prelude::{coins_bip39::English, *}; use futures::future::FutureExt; @@ -16,6 +17,14 @@ use sequencer_utils::deployer::{ use std::{fs::File, io::stdout, path::PathBuf}; use url::Url; +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +enum ContractGroup { + #[clap(name = "hotshot")] + HotShot, + FeeContract, + LightClient, +} + /// Deploy contracts needed to run the sequencer. /// /// This script deploys contracts needed to run the sequencer to an L1. It outputs a .env file @@ -73,6 +82,10 @@ struct Options { )] account_index: u32, + /// Only deploy the given groups of related contracts. + #[clap(long, value_delimiter = ',')] + only: Option>, + /// Write deployment results to OUT as a .env file. /// /// If not provided, the results will be written to stdout. @@ -91,6 +104,13 @@ struct Options { pub stake_table_capacity: usize, } +fn should_deploy(group: ContractGroup, only: &Option>) -> bool { + match only { + Some(groups) => groups.contains(&group), + None => true, + } +} + #[async_std::main] async fn main() -> anyhow::Result<()> { setup_logging(); @@ -118,37 +138,61 @@ async fn main() -> anyhow::Result<()> { ); tracing::info!(%balance, "deploying from address {owner:#x}"); - contracts - .deploy_tx(Contract::HotShot, HotShot::deploy(l1.clone(), ())?) - .await?; + // `HotShot.sol` + if should_deploy(ContractGroup::HotShot, &opt.only) { + contracts + .deploy_tx(Contract::HotShot, HotShot::deploy(l1.clone(), ())?) + .await?; + } - // Deploy the upgradable light client contract first, then initialize it through a proxy contract - let lc_address = if opt.use_mock_contract { + // `LightClient.sol` + if should_deploy(ContractGroup::LightClient, &opt.only) { + // Deploy the upgradable light client contract first, then initialize it through a proxy contract + let lc_address = if opt.use_mock_contract { + contracts + .deploy_fn(Contract::LightClient, |contracts| { + deploy_mock_light_client_contract(l1.clone(), contracts, None).boxed() + }) + .await? + } else { + contracts + .deploy_fn(Contract::LightClient, |contracts| { + deploy_light_client_contract(l1.clone(), contracts).boxed() + }) + .await? + }; + let light_client = LightClient::new(lc_address, l1.clone()); + + let genesis = light_client_genesis(&opt.orchestrator_url, opt.stake_table_capacity).await?; + let data = light_client + .initialize(genesis.into(), u32::MAX, owner) + .calldata() + .context("calldata for initialize transaction not available")?; contracts - .deploy_fn(Contract::LightClient, |contracts| { - deploy_mock_light_client_contract(l1.clone(), contracts, None).boxed() - }) - .await? - } else { + .deploy_tx( + Contract::LightClientProxy, + ERC1967Proxy::deploy(l1.clone(), (lc_address, data))?, + ) + .await?; + } + + // `FeeContract.sol` + if should_deploy(ContractGroup::FeeContract, &opt.only) { + let fee_contract_address = contracts + .deploy_tx(Contract::FeeContract, FeeContract::deploy(l1.clone(), ())?) + .await?; + let fee_contract = FeeContract::new(fee_contract_address, l1.clone()); + let data = fee_contract + .initialize(owner) + .calldata() + .context("calldata for initialize transaction not available")?; contracts - .deploy_fn(Contract::LightClient, |contracts| { - deploy_light_client_contract(l1.clone(), contracts).boxed() - }) - .await? - }; - let light_client = LightClient::new(lc_address, l1.clone()); - - let genesis = light_client_genesis(&opt.orchestrator_url, opt.stake_table_capacity).await?; - let data = light_client - .initialize(genesis.into(), u32::MAX, owner) - .calldata() - .context("calldata for initialize transaction not available")?; - contracts - .deploy_tx( - Contract::LightClientProxy, - ERC1967Proxy::deploy(l1.clone(), (lc_address, data))?, - ) - .await?; + .deploy_tx( + Contract::FeeContractProxy, + ERC1967Proxy::deploy(l1.clone(), (fee_contract_address, data))?, + ) + .await?; + } if let Some(out) = &opt.out { let file = File::options() diff --git a/sequencer/src/block.rs b/sequencer/src/block.rs index 5ab60a896..06000d406 100644 --- a/sequencer/src/block.rs +++ b/sequencer/src/block.rs @@ -187,7 +187,7 @@ mod reference { fn test_reference_header() { reference_test::( HEADER.clone(), - "BLOCK~00ISpu2jHbXD6z-BwMkwR4ijGdgUSoXLp_2jIStmqBrD", + "BLOCK~Wg0AQ-1-7OZ1MjxYnD_KYPj4LSP1BW1wAKMfemBDvQOi", |header| header.commit(), ); } diff --git a/sequencer/src/block/payload.rs b/sequencer/src/block/payload.rs index fa8bb99e3..023c9399c 100644 --- a/sequencer/src/block/payload.rs +++ b/sequencer/src/block/payload.rs @@ -170,7 +170,7 @@ impl Payload { block_size += size_of::() as u64; } - if block_size > chain_config.max_block_size() { + if block_size > chain_config.max_block_size { break; } @@ -372,7 +372,10 @@ mod test { assert_eq!(tx_size, 10); let n_txs = target_payload_total as u64 / tx_size; - let chain_config = ChainConfig::new(1, max_block_size, 1); + let chain_config = ChainConfig { + max_block_size, + ..Default::default() + }; let mut txs = (0..n_txs) .map(|_| Transaction::of_size(payload_size)) diff --git a/sequencer/src/chain_config.rs b/sequencer/src/chain_config.rs index 41c52ee80..ae89ae841 100644 --- a/sequencer/src/chain_config.rs +++ b/sequencer/src/chain_config.rs @@ -1,58 +1,77 @@ -use crate::state::FeeAmount; +use crate::{ + options::parse_size, + state::{FeeAccount, FeeAmount}, +}; +use clap::Args; use committable::{Commitment, Committable}; use derive_more::{From, Into}; -use ethers::types::U256; +use ethers::types::{Address, U256}; use itertools::Either; use sequencer_utils::impl_to_fixed_bytes; use serde::{Deserialize, Serialize}; +use std::str::FromStr; #[derive(Default, Hash, Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq, From, Into)] pub struct ChainId(U256); impl_to_fixed_bytes!(ChainId, U256); -impl From for ChainId { - fn from(id: u16) -> Self { +impl From for ChainId { + fn from(id: u64) -> Self { Self(id.into()) } } +impl FromStr for ChainId { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + Ok(u64::from_str(s)?.into()) + } +} + /// Global variables for an Espresso blockchain. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Args, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ChainConfig { /// Espresso chain ID - chain_id: ChainId, + #[clap(long, env = "ESPRESSO_SEQUENCER_CHAIN_ID", default_value = "0")] + pub chain_id: ChainId, /// Maximum size in bytes of a block - max_block_size: u64, + #[clap(long, env = "ESPRESSO_SEQUENCER_MAX_BLOCK_SIZE", default_value = "30mb", value_parser = parse_size)] + pub max_block_size: u64, /// Minimum fee in WEI per byte of payload - base_fee: FeeAmount, + #[clap(long, env = "ESPRESSO_SEQUENCER_BASE_FEE", default_value = "0")] + pub base_fee: FeeAmount, + /// Fee contract address on L1. + /// + /// This is optional so that fees can easily be toggled on/off, with no need to deploy a + /// contract when they are off. In a future release, after fees are switched on and thoroughly + /// tested, this may be made mandatory. + #[clap(long, env = "ESPRESSO_SEQUENCER_FEE_CONTRACT_PROXY_ADDRESS")] + pub fee_contract: Option
, + /// Account that receives sequencing fees. + /// + /// This account in the Espresso fee ledger will always receive every fee paid in Espresso, + /// regardless of whether or not their is a `fee_contract` deployed. Once deployed, the fee + /// contract can decide what to do with tokens locked in this account in Espresso. + #[clap( + long, + env = "ESPRESSO_SEQUENCER_FEE_RECIPIENT", + default_value = "0x0000000000000000000000000000000000000000" + )] + pub fee_recipient: FeeAccount, } impl Default for ChainConfig { fn default() -> Self { - Self::new( - U256::from(35353), // arbitrarily chosen chain ID - 10240, // 10 kB max_block_size - 0, // no fees - ) - } -} - -impl ChainConfig { - pub fn new( - chain_id: impl Into, - max_block_size: u64, - base_fee: impl Into, - ) -> Self { Self { - chain_id: chain_id.into(), - max_block_size, - base_fee: base_fee.into(), + chain_id: U256::from(35353).into(), // arbitrarily chosen chain ID + max_block_size: 10240, + base_fee: 0.into(), + fee_contract: None, + fee_recipient: Default::default(), } } - pub fn max_block_size(&self) -> u64 { - self.max_block_size - } } impl Committable for ChainConfig { @@ -61,11 +80,17 @@ impl Committable for ChainConfig { } fn commit(&self) -> Commitment { - committable::RawCommitmentBuilder::new(&Self::tag()) + let comm = committable::RawCommitmentBuilder::new(&Self::tag()) .fixed_size_field("chain_id", &self.chain_id.to_fixed_bytes()) .u64_field("max_block_size", self.max_block_size) .fixed_size_field("base_fee", &self.base_fee.to_fixed_bytes()) - .finalize() + .fixed_size_field("fee_recipient", &self.fee_recipient.to_fixed_bytes()); + let comm = if let Some(addr) = self.fee_contract { + comm.u64_field("fee_contract", 1).fixed_size_bytes(&addr.0) + } else { + comm.u64_field("fee_contract", 0) + }; + comm.finalize() } } @@ -118,7 +143,12 @@ mod tests { max_block_size, .. } = chain_config; - let other_config = ChainConfig::new(chain_id, max_block_size, 1); + let other_config = ChainConfig { + chain_id, + max_block_size, + base_fee: 1.into(), + ..Default::default() + }; assert!(chain_config != other_config); } diff --git a/sequencer/src/eth_signature_key.rs b/sequencer/src/eth_signature_key.rs index d19d23101..da822372b 100644 --- a/sequencer/src/eth_signature_key.rs +++ b/sequencer/src/eth_signature_key.rs @@ -55,6 +55,10 @@ impl EthKeyPair { pub fn address(&self) -> Address { self.fee_account.address() } + + pub fn signer(&self) -> LocalWallet { + LocalWallet::from_bytes(&self.signing_key.to_bytes()).unwrap() + } } impl Hash for EthKeyPair { @@ -108,9 +112,11 @@ impl Ord for EthKeyPair { #[derive(Clone, Debug, Snafu)] pub struct SigningError; +pub type BuilderSignature = Signature; + impl BuilderSignatureKey for FeeAccount { type BuilderPrivateKey = EthKeyPair; - type BuilderSignature = Signature; + type BuilderSignature = BuilderSignature; type SignError = SigningError; fn validate_builder_signature(&self, signature: &Self::BuilderSignature, data: &[u8]) -> bool { @@ -121,7 +127,7 @@ impl BuilderSignatureKey for FeeAccount { private_key: &Self::BuilderPrivateKey, data: &[u8], ) -> Result { - let wallet = LocalWallet::from_bytes(&private_key.signing_key.to_bytes()).unwrap(); + let wallet = private_key.signer(); let message_hash = ethers::utils::hash_message(data); wallet.sign_hash(message_hash).map_err(|_| SigningError) } @@ -190,12 +196,12 @@ mod tests { let sig = FeeAccount::sign_builder_message(&key, msg).unwrap(); assert!(key.fee_account().validate_builder_signature(&sig, msg)); - // Recovery fails if signed with other key + // Validation fails if signed with other key. let other_key = FeeAccount::generated_from_seed_indexed([0u8; 32], 1).1; let sig = FeeAccount::sign_builder_message(&other_key, msg).unwrap(); assert!(!key.fee_account().validate_builder_signature(&sig, msg)); - // Recovery fails if another message was signed + // Validation fails if another message was signed let sig = FeeAccount::sign_builder_message(&key, b"hello world XYZ").unwrap(); assert!(!key.fee_account().validate_builder_signature(&sig, msg)); } diff --git a/sequencer/src/header.rs b/sequencer/src/header.rs index c16adcb74..c1c21eb14 100644 --- a/sequencer/src/header.rs +++ b/sequencer/src/header.rs @@ -1,20 +1,21 @@ use crate::{ block::{entry::TxTableEntryWord, tables::NameSpaceTable, NsTable}, chain_config::ResolvableChainConfig, + eth_signature_key::BuilderSignature, l1_client::L1Snapshot, - state::{BlockMerkleCommitment, FeeAccount, FeeInfo, FeeMerkleCommitment}, + state::{BlockMerkleCommitment, FeeInfo, FeeMerkleCommitment}, ChainConfig, L1BlockInfo, Leaf, NodeState, SeqTypes, ValidatedState, }; -use anyhow::Context; +use anyhow::{ensure, Context}; use ark_serialize::CanonicalSerialize; use committable::{Commitment, Committable, RawCommitmentBuilder}; -use ethers::types; use hotshot_query_service::availability::QueryableHeader; use hotshot_types::{ traits::{ block_contents::{BlockHeader, BlockPayload, BuilderFee}, node_implementation::NodeType, - EncodeBytes, ValidatedState as HotShotState, + signature_key::BuilderSignatureKey, + ValidatedState as HotShotState, }, utils::BuilderCommitment, vid::{VidCommitment, VidCommon}, @@ -81,9 +82,17 @@ pub struct Header { pub block_merkle_tree_root: BlockMerkleCommitment, /// Root Commitment of `FeeMerkleTree` pub fee_merkle_tree_root: FeeMerkleCommitment, - /// Account (etheruem address) of builder - pub builder_signature: Option, + /// Fee paid by the block builder pub fee_info: FeeInfo, + /// Account (etheruem address) of builder + /// + /// This signature is not considered formally part of the header; it is just evidence proving + /// that other parts of the header (`fee_info`) are correct. It exists in the header so that it + /// is available to all nodes to be used during validation. But since it is checked during + /// consensus, any downstream client who has a proof of consensus finality of a header can trust + /// that `fee_info` is correct without relying on the signature. Thus, this signature is not + /// included in the header commitment. + pub builder_signature: Option, } impl Committable for Header { @@ -142,8 +151,7 @@ impl Header { parent_leaf: &Leaf, mut l1: L1Snapshot, l1_deposits: &[FeeInfo], - builder_fee: FeeInfo, - builder_signature: types::Signature, + builder_fee: BuilderFee, mut timestamp: u64, mut state: ValidatedState, chain_config: ChainConfig, @@ -206,9 +214,20 @@ impl Header { } // Charge the builder fee. + ensure!( + builder_fee.fee_account.validate_fee_signature( + &builder_fee.fee_signature, + builder_fee.fee_amount, + &ns_table, + &payload_commitment, + ), + "invalid builder signature" + ); + let builder_signature = Some(builder_fee.fee_signature); + let fee_info = builder_fee.into(); state - .charge_fee(builder_fee) - .context(format!("invalid builder fee {builder_fee:?}"))?; + .charge_fee(fee_info, chain_config.fee_recipient) + .context(format!("invalid builder fee {fee_info:?}"))?; let fee_merkle_tree_root = state.fee_merkle_tree.commitment(); Ok(Self { @@ -222,26 +241,10 @@ impl Header { ns_table, fee_merkle_tree_root, block_merkle_tree_root, - fee_info: builder_fee, - builder_signature: Some(builder_signature), + fee_info, + builder_signature, }) } - - /// Message authorizing a fee payment for inclusion of a certain payload. - /// - /// This message relates the fee info in this header to the payload corresponding to the header. - /// The message is signed by the builder (or whoever is paying for inclusion of the block) and - /// validated by consensus, as authentication for charging the fee to the builder account. - pub fn fee_message(&self) -> anyhow::Result> { - Ok(fee_message( - self.fee_info.amount().as_u64().context(format!( - "fee amount out of range: {:?}", - self.fee_info.amount() - ))?, - self.payload_commitment, - self.metadata(), - )) - } } #[derive(Debug, Snafu)] @@ -283,32 +286,22 @@ impl BlockHeader for Header { builder_fee: BuilderFee, _vid_common: VidCommon, ) -> Result { + let chain_config = instance_state.chain_config; let height = parent_leaf.get_height(); let view = parent_leaf.get_view_number(); let mut validated_state = parent_state.clone(); - // Validate the builder's signature, recovering their fee account address so that we can - // fetch the account state if missing. - let fee_msg = fee_message(builder_fee.fee_amount, payload_commitment, &metadata); - let builder_account = FeeAccount::from( - builder_fee - .fee_signature - .recover(fee_msg) - .context("invalid builder signature")?, - ); - - // Figure out which accounts we need to fetch, in case we are missing some state needed to - // construct the header. - let accounts = std::iter::once(builder_account); - // Fetch the latest L1 snapshot. let l1_snapshot = instance_state.l1_client().snapshot().await; // Fetch the new L1 deposits between parent and current finalized L1 block. - let l1_deposits = if let Some(block_info) = l1_snapshot.finalized { + let l1_deposits = if let (Some(addr), Some(block_info)) = + (chain_config.fee_contract, l1_snapshot.finalized) + { instance_state .l1_client .get_finalized_deposits( + addr, parent_leaf .get_block_header() .l1_finalized @@ -319,9 +312,14 @@ impl BlockHeader for Header { } else { vec![] }; - // Find missing fee state entries - let missing_accounts = parent_state - .forgotten_accounts(accounts.chain(l1_deposits.iter().map(|info| info.account()))); + // Find missing fee state entries. We will need to use the builder account which is paying a + // fee and the recipient account which is receiving it, plus any counts receiving deposits + // in this block. + let missing_accounts = parent_state.forgotten_accounts( + [builder_fee.fee_account, chain_config.fee_recipient] + .into_iter() + .chain(l1_deposits.iter().map(|info| info.account())), + ); if !missing_accounts.is_empty() { tracing::warn!( height, @@ -369,11 +367,10 @@ impl BlockHeader for Header { parent_leaf, l1_snapshot, &l1_deposits, - FeeInfo::new(builder_account, builder_fee.fee_amount), - builder_fee.fee_signature, + builder_fee, OffsetDateTime::now_utc().unix_timestamp() as u64, validated_state, - instance_state.chain_config, + chain_config, )?) } @@ -426,18 +423,6 @@ impl BlockHeader for Header { } } -fn fee_message( - amount: u64, - payload_commitment: VidCommitment, - metadata: &<::BlockPayload as BlockPayload>::Metadata, -) -> Vec { - let mut data = vec![]; - data.extend_from_slice(amount.to_be_bytes().as_ref()); - data.extend_from_slice(metadata.encode().as_ref()); - data.extend_from_slice(payload_commitment.as_ref()); - data -} - impl QueryableHeader for Header { fn timestamp(&self) -> u64 { self.timestamp @@ -453,10 +438,7 @@ mod test_headers { catchup::mock::MockStateCatchup, eth_signature_key::EthKeyPair, l1_client::L1Client, - state::{ - apply_proposal, get_l1_deposits, validate_proposal, BlockMerkleTree, Delta, - FeeMerkleTree, - }, + state::{validate_proposal, BlockMerkleTree, FeeAccount, FeeMerkleTree}, NodeState, }; use async_compatibility_layer::logging::{setup_backtrace, setup_logging}; @@ -540,8 +522,11 @@ mod test_headers { finalized: self.l1_finalized, }, &self.l1_deposits, - FeeInfo::new(fee_account, fee_amount), - fee_signature, + BuilderFee { + fee_account, + fee_amount, + fee_signature, + }, self.timestamp, validated_state.clone(), genesis.instance_state.chain_config, @@ -737,8 +722,8 @@ mod test_headers { } } - #[test] - fn test_validate_proposal_error_cases() { + #[async_std::test] + async fn test_validate_proposal_error_cases() { let genesis = GenesisForTest::default(); let vid_common = vid_scheme(1).disperse([]).unwrap().common; @@ -756,13 +741,19 @@ mod test_headers { parent_header.block_merkle_tree_root = block_merkle_tree_root; let mut proposal = parent_header.clone(); - let mut delta = Delta::default(); // Pass a different chain config to trigger a chain config validation error. - let state = apply_proposal(&validated_state, &mut delta, &parent_leaf, vec![]); + let state = validated_state + .apply_header(&genesis.instance_state, &parent_leaf, &proposal) + .await + .unwrap() + .0; let result = validate_proposal( &state, - ChainConfig::new(U256::zero(), 0u64, U256::zero()), + ChainConfig { + chain_id: U256::zero().into(), + ..Default::default() + }, &parent_leaf, &proposal, &vid_common, @@ -773,7 +764,11 @@ mod test_headers { // Advance `proposal.height` to trigger validation error. - let validated_state = apply_proposal(&validated_state, &mut delta, &parent_leaf, vec![]); + let validated_state = validated_state + .apply_header(&genesis.instance_state, &parent_leaf, &proposal) + .await + .unwrap() + .0; let result = validate_proposal( &validated_state, genesis.instance_state.chain_config, @@ -790,7 +785,11 @@ mod test_headers { // proposed `Header` root should include parent + parent.commit proposal.height += 1; - let validated_state = apply_proposal(&validated_state, &mut delta, &parent_leaf, vec![]); + let validated_state = validated_state + .apply_header(&genesis.instance_state, &parent_leaf, &proposal) + .await + .unwrap() + .0; let result = validate_proposal( &validated_state, @@ -810,11 +809,8 @@ mod test_headers { setup_backtrace(); let anvil = Anvil::new().block_time(1u32).spawn(); - let mut genesis_state = NodeState::mock().with_l1(L1Client::new( - anvil.endpoint().parse().unwrap(), - Address::default(), - 1, - )); + let mut genesis_state = + NodeState::mock().with_l1(L1Client::new(anvil.endpoint().parse().unwrap(), 1)); let genesis = GenesisForTest::default(); let vid_common = vid_scheme(1).disperse([]).unwrap().common; @@ -876,7 +872,7 @@ mod test_headers { let mut proposal_state = parent_state.clone(); for fee_info in genesis_state .l1_client - .get_finalized_deposits(None, 0) + .get_finalized_deposits(Address::default(), None, 0) .await { proposal_state.insert_fee_deposit(fee_info).unwrap(); @@ -885,11 +881,11 @@ mod test_headers { let mut block_merkle_tree = proposal_state.block_merkle_tree.clone(); block_merkle_tree.push(proposal.commit()).unwrap(); - let l1_deposits = get_l1_deposits(&genesis_state, &proposal, &parent_leaf).await; - - let mut delta = Delta::default(); - - let proposal_state = apply_proposal(&proposal_state, &mut delta, &parent_leaf, l1_deposits); + let proposal_state = proposal_state + .apply_header(&genesis_state, &parent_leaf, &proposal) + .await + .unwrap() + .0; validate_proposal( &proposal_state, genesis.instance_state.chain_config, diff --git a/sequencer/src/hotshot_commitment.rs b/sequencer/src/hotshot_commitment.rs index 0e3012d77..fe79f2ec1 100644 --- a/sequencer/src/hotshot_commitment.rs +++ b/sequencer/src/hotshot_commitment.rs @@ -375,11 +375,8 @@ mod test { let num_batches = l1.hotshot.max_blocks().call().await.unwrap().as_usize(); let mut data = MockDataSource::default(); - let node_state = NodeState::mock().with_l1(L1Client::new( - anvil.provider().url().clone(), - Address::default(), - 1, - )); + let node_state = + NodeState::mock().with_l1(L1Client::new(anvil.provider().url().clone(), 1)); for i in 0..num_batches { data.leaves.push(Some(mock_leaf(i as u64, &node_state))); @@ -448,11 +445,8 @@ mod test { // Create a test batch. let mut data = MockDataSource::default(); - let node_state = NodeState::mock().with_l1(L1Client::new( - anvil.provider().url().clone(), - Address::default(), - 1, - )); + let node_state = + NodeState::mock().with_l1(L1Client::new(anvil.provider().url().clone(), 1)); data.leaves.push(Some(mock_leaf(0, &node_state))); // Connect to the HotShot contract with the expected L1 client. @@ -513,11 +507,8 @@ mod test { .unwrap(), ); - let node_state = NodeState::mock().with_l1(L1Client::new( - anvil.provider().url().clone(), - Address::default(), - 1, - )); + let node_state = + NodeState::mock().with_l1(L1Client::new(anvil.provider().url().clone(), 1)); // Create a sequence of leaves, some of which are missing. let mut data = MockDataSource::default(); diff --git a/sequencer/src/l1_client.rs b/sequencer/src/l1_client.rs index 42e3a3f21..ed127dbe9 100644 --- a/sequencer/src/l1_client.rs +++ b/sequencer/src/l1_client.rs @@ -20,6 +20,7 @@ use crate::state::FeeInfo; use anyhow::Context; use async_std::task::sleep; use committable::{Commitment, Committable, RawCommitmentBuilder}; +use contract_bindings::fee_contract::FeeContract; use ethers::prelude::*; use futures::{ join, @@ -98,20 +99,18 @@ impl Committable for L1BlockInfo { pub struct L1Client { retry_delay: Duration, /// `Provider` from `ethers-provider`. - provider: Provider, - /// `Address` of fee contract. - _address: Address, + provider: Arc>, /// Maximum number of L1 blocks that can be scanned for events in a single query. events_max_block_range: u64, } impl L1Client { /// Instantiate an `L1Client` for a given `Url`. - pub fn new(url: Url, contract_address: Address, events_max_block_range: u64) -> Self { + pub fn new(url: Url, events_max_block_range: u64) -> Self { + let provider = Arc::new(Provider::new(Http::new(url))); Self { retry_delay: Duration::from_secs(1), - provider: Provider::new(Http::new(url)), - _address: contract_address, + provider, events_max_block_range, } } @@ -164,6 +163,7 @@ impl L1Client { /// and `new`. Returns `Vec` pub async fn get_finalized_deposits( &self, + fee_contract_address: Address, prev_finalized: Option, new_finalized: u64, ) -> Vec { @@ -195,24 +195,20 @@ impl L1Client { // Fetch events for each chunk. let events = stream::iter(chunks).then(|(from, to)| { - let address = self._address; - let provider = self.provider.clone(); let retry_delay = self.retry_delay; + let fee_contract = FeeContract::new(fee_contract_address, self.provider.clone()); async move { tracing::debug!(from, to, "fetch events in range"); // query for deposit events, loop until successful. loop { - match contract_bindings::fee_contract::FeeContract::new( - address, - Arc::new(&provider), - ) - .deposit_filter() - .address(address.into()) - .from_block(from) - .to_block(to) - .query() - .await + match fee_contract + .deposit_filter() + .address(fee_contract.address().into()) + .from_block(from) + .to_block(to) + .query() + .await { Ok(events) => break stream::iter(events), Err(err) => { @@ -274,7 +270,7 @@ mod test { // Test l1_client methods against `ethers::Provider`. There is // also some sanity testing demonstrating `Anvil` availability. let anvil = Anvil::new().block_time(1u32).spawn(); - let l1_client = L1Client::new(anvil.endpoint().parse().unwrap(), Address::default(), 1); + let l1_client = L1Client::new(anvil.endpoint().parse().unwrap(), 1); let provider = &l1_client.provider; let version = provider.client_version().await.unwrap(); @@ -282,11 +278,7 @@ mod test { // Test that nothing funky is happening to the provider when // passed along in state. - let state = NodeState::mock().with_l1(L1Client::new( - anvil.endpoint().parse().unwrap(), - Address::default(), - 1, - )); + let state = NodeState::mock().with_l1(L1Client::new(anvil.endpoint().parse().unwrap(), 1)); let version = state.l1_client().provider.client_version().await.unwrap(); assert_eq!("anvil/v0.2.0", version); @@ -319,7 +311,7 @@ mod test { let anvil = Anvil::new().spawn(); let wallet_address = anvil.addresses().first().cloned().unwrap(); - let l1_client = L1Client::new(anvil.endpoint().parse().unwrap(), Address::default(), 1); + let l1_client = L1Client::new(anvil.endpoint().parse().unwrap(), 1); let wallet: LocalWallet = anvil.keys()[0].clone().into(); // In order to deposit we need a provider that can sign. @@ -384,16 +376,16 @@ mod test { assert_eq!(deposits + deploy_txn_count, head); // Use non-signing `L1Client` to retrieve data. - let l1_client = L1Client::new( - anvil.endpoint().parse().unwrap(), - fee_contract_proxy.address(), - 1, - ); + let l1_client = L1Client::new(anvil.endpoint().parse().unwrap(), 1); // Set prev deposits to `None` so `Filter` will start at block // 0. The test would also succeed if we pass `0` (b/c first // block did not deposit). let pending = l1_client - .get_finalized_deposits(None, deposits + deploy_txn_count) + .get_finalized_deposits( + fee_contract_proxy.address(), + None, + deposits + deploy_txn_count, + ) .await; assert_eq!(deposits as usize, pending.len(), "{pending:?}"); @@ -406,29 +398,45 @@ mod test { // check a few more cases let pending = l1_client - .get_finalized_deposits(Some(0), deposits + deploy_txn_count) + .get_finalized_deposits( + fee_contract_proxy.address(), + Some(0), + deposits + deploy_txn_count, + ) .await; assert_eq!(deposits as usize, pending.len()); - let pending = l1_client.get_finalized_deposits(Some(0), 0).await; + let pending = l1_client + .get_finalized_deposits(fee_contract_proxy.address(), Some(0), 0) + .await; assert_eq!(0, pending.len()); - let pending = l1_client.get_finalized_deposits(Some(0), 1).await; + let pending = l1_client + .get_finalized_deposits(fee_contract_proxy.address(), Some(0), 1) + .await; assert_eq!(0, pending.len()); let pending = l1_client - .get_finalized_deposits(Some(deploy_txn_count), deploy_txn_count) + .get_finalized_deposits( + fee_contract_proxy.address(), + Some(deploy_txn_count), + deploy_txn_count, + ) .await; assert_eq!(0, pending.len()); let pending = l1_client - .get_finalized_deposits(Some(deploy_txn_count), deploy_txn_count + 1) + .get_finalized_deposits( + fee_contract_proxy.address(), + Some(deploy_txn_count), + deploy_txn_count + 1, + ) .await; assert_eq!(1, pending.len()); // what happens if `new_finalized` is `0`? let pending = l1_client - .get_finalized_deposits(Some(deploy_txn_count), 0) + .get_finalized_deposits(fee_contract_proxy.address(), Some(deploy_txn_count), 0) .await; assert_eq!(0, pending.len()); diff --git a/sequencer/src/lib.rs b/sequencer/src/lib.rs index 9a581677b..23151d373 100644 --- a/sequencer/src/lib.rs +++ b/sequencer/src/lib.rs @@ -187,15 +187,15 @@ impl NodeState { Self::new( 0, ChainConfig::default(), - L1Client::new( - "http://localhost:3331".parse().unwrap(), - Address::default(), - 10000, - ), + L1Client::new("http://localhost:3331".parse().unwrap(), 10000), catchup::mock::MockStateCatchup::default(), ) } + pub fn chain_config(&self) -> &ChainConfig { + &self.chain_config + } + pub fn with_l1(mut self, l1_client: L1Client) -> Self { self.l1_client = l1_client; self @@ -206,6 +206,11 @@ impl NodeState { self } + pub fn with_chain_config(mut self, cfg: ChainConfig) -> Self { + self.chain_config = cfg; + self + } + fn l1_client(&self) -> &L1Client { &self.l1_client } @@ -219,11 +224,7 @@ impl Default for NodeState { Self::new( 1u64, ChainConfig::default(), - L1Client::new( - "http://localhost:3331".parse().unwrap(), - Address::default(), - 10000, - ), + L1Client::new("http://localhost:3331".parse().unwrap(), 10000), catchup::mock::MockStateCatchup::default(), ) } @@ -450,11 +451,7 @@ pub async fn init_node( genesis_state.prefund_account(address.into(), U256::max_value().into()); } - let l1_client = L1Client::new( - l1_params.url, - Address::default(), - l1_params.events_max_block_range, - ); + let l1_client = L1Client::new(l1_params.url, l1_params.events_max_block_range); let l1_genesis = match l1_params.finalized_block { Some(block) => Some(l1_client.get_block(block).await?), None => None, @@ -587,7 +584,6 @@ pub mod testing { my_own_validator_config: Default::default(), view_sync_timeout: Duration::from_secs(1), data_request_delay: Duration::from_secs(1), - //?? builder_url: Url::parse(&format!( "http://127.0.0.1:{}", pick_unused_port().unwrap() @@ -638,7 +634,6 @@ pub mod testing { &NoMetrics, STAKE_TABLE_CAPACITY_FOR_TEST, bind_version, - true, ) .await })) @@ -655,18 +650,15 @@ pub mod testing { metrics: &dyn Metrics, stake_table_capacity: usize, bind_version: Ver, - is_da: bool, ) -> SequencerContext { let mut config = self.config.clone(); + let my_peer_config = &config.known_nodes_with_stake[i]; config.my_own_validator_config = ValidatorConfig { - public_key: config.known_nodes_with_stake[i].stake_table_entry.stake_key, + public_key: my_peer_config.stake_table_entry.stake_key, private_key: self.priv_keys[i].clone(), - stake_value: config.known_nodes_with_stake[i] - .stake_table_entry - .stake_amount - .as_u64(), + stake_value: my_peer_config.stake_table_entry.stake_amount.as_u64(), state_key_pair: self.state_key_pairs[i].clone(), - is_da, + is_da: config.known_da_nodes.contains(my_peer_config), }; let network = Arc::new(MemoryNetwork::new( @@ -688,11 +680,7 @@ pub mod testing { let node_state = NodeState::new( i as u64, ChainConfig::default(), - L1Client::new( - self.anvil.endpoint().parse().unwrap(), - Address::default(), - 10000, - ), + L1Client::new(self.anvil.endpoint().parse().unwrap(), 10000), catchup::local_and_remote(persistence_opt.clone(), catchup).await, ) .with_genesis(state); diff --git a/sequencer/src/main.rs b/sequencer/src/main.rs index 93046e6d3..da20cfc26 100644 --- a/sequencer/src/main.rs +++ b/sequencer/src/main.rs @@ -9,7 +9,7 @@ use sequencer::{ api::{self, data_source::DataSourceOptions}, init_node, options::{Modules, Options}, - persistence, BuilderParams, ChainConfig, L1Params, NetworkParams, + persistence, BuilderParams, L1Params, NetworkParams, }; use vbs::version::StaticVersionType; @@ -50,7 +50,6 @@ where { let (private_staking_key, private_state_key) = opt.private_keys()?; let stake_table_capacity = opt.stake_table_capacity; - let chain_config = ChainConfig::new(opt.chain_id, opt.max_block_size, opt.base_fee); let l1_params = L1Params { url: opt.l1_provider_url, finalized_block: opt.l1_genesis, @@ -125,7 +124,7 @@ where l1_params, stake_table_capacity, bind_version, - chain_config, + opt.chain_config, opt.is_da, ) .await @@ -146,7 +145,7 @@ where l1_params, stake_table_capacity, bind_version, - chain_config, + opt.chain_config, opt.is_da, ) .await? diff --git a/sequencer/src/options.rs b/sequencer/src/options.rs index 3316d1762..49ad03dbc 100644 --- a/sequencer/src/options.rs +++ b/sequencer/src/options.rs @@ -1,4 +1,4 @@ -use crate::{api, persistence}; +use crate::{api, persistence, ChainConfig}; use anyhow::{bail, Context}; use bytesize::ByteSize; use clap::{error::ErrorKind, Args, FromArgMatches, Parser}; @@ -6,7 +6,7 @@ use cld::ClDuration; use core::fmt::Display; use derivative::Derivative; use derive_more::From; -use ethers::types::{Address, U256}; +use ethers::types::Address; use hotshot_stake_table::config::STAKE_TABLE_CAPACITY; use hotshot_types::light_client::StateSignKey; use hotshot_types::signature_key::BLSPrivKey; @@ -43,10 +43,6 @@ use url::Url; #[derive(Parser, Clone, Derivative)] #[derivative(Debug(bound = ""))] pub struct Options { - /// Unique identifier for this instance of the sequencer network. - #[clap(long, env = "ESPRESSO_SEQUENCER_CHAIN_ID", default_value = "0")] - pub chain_id: u16, - /// URL of the HotShot orchestrator. #[clap( short, @@ -181,13 +177,8 @@ pub struct Options { #[clap(long, env = "ESPRESSO_SEQUENCER_STAKE_TABLE_CAPACITY", default_value_t = STAKE_TABLE_CAPACITY)] pub stake_table_capacity: usize, - /// Maximum size in bytes of a block - #[clap(long, env = "ESPRESSO_SEQUENCER_MAX_BLOCK_SIZE", value_parser = parse_size)] - pub max_block_size: u64, - - #[clap(long, env = "ESPRESSO_SEQUENCER_BASE_FEE")] - /// Minimum fee in WEI per byte of payload - pub base_fee: U256, + #[clap(flatten)] + pub chain_config: ChainConfig, } impl Options { diff --git a/sequencer/src/state.rs b/sequencer/src/state.rs index 0fe955a44..d9d66d172 100644 --- a/sequencer/src/state.rs +++ b/sequencer/src/state.rs @@ -11,7 +11,7 @@ use async_std::sync::RwLock; use committable::{Commitment, Committable, RawCommitmentBuilder}; use contract_bindings::fee_contract::DepositFilter; use core::fmt::Debug; -use derive_more::{Add, Display, From, Into, Sub}; +use derive_more::{Add, Display, From, Into, Mul, Sub}; use ethers::{abi::Address, types::U256}; use futures::future::Future; use hotshot::traits::ValidatedState as HotShotState; @@ -24,7 +24,10 @@ use hotshot_query_service::{ use hotshot_types::{ data::{BlockError, ViewNumber}, traits::{ - node_implementation::ConsensusTime, signature_key::BuilderSignatureKey, states::StateDelta, + block_contents::{BlockHeader, BuilderFee}, + node_implementation::ConsensusTime, + signature_key::BuilderSignatureKey, + states::StateDelta, }, vid::{VidCommon, VidSchemeType}, }; @@ -33,7 +36,8 @@ use jf_merkle_tree::{ prelude::{LightWeightSHA3MerkleTree, MerkleProof, Sha3Digest, Sha3Node}, universal_merkle_tree::UniversalMerkleTree, AppendableMerkleTreeScheme, ForgetableMerkleTreeScheme, ForgetableUniversalMerkleTreeScheme, - LookupResult, MerkleCommitment, MerkleTreeScheme, ToTraversalPath, UniversalMerkleTreeScheme, + LookupResult, MerkleCommitment, MerkleTreeScheme, PersistentUniversalMerkleTreeScheme, + ToTraversalPath, UniversalMerkleTreeScheme, }; use jf_vid::VidScheme; use num_traits::CheckedSub; @@ -90,6 +94,14 @@ impl ValidatedState { self.fee_merkle_tree.update(account, amount).unwrap(); } + pub fn balance(&mut self, account: FeeAccount) -> Option { + match self.fee_merkle_tree.lookup(account) { + LookupResult::Ok(balance, _) => Some(*balance), + LookupResult::NotFound(_) => Some(0.into()), + LookupResult::NotInMemory => None, + } + } + /// Find accounts that are not in memory. /// /// As an optimization we could try to apply updates and return the @@ -135,11 +147,14 @@ impl ValidatedState { })?) } - /// Charge a fee to an account. - pub fn charge_fee(&mut self, fee_info: FeeInfo) -> anyhow::Result<()> { + /// Charge a fee to an account, transferring the funds to the fee recipient account. + pub fn charge_fee(&mut self, fee_info: FeeInfo, recipient: FeeAccount) -> anyhow::Result<()> { + let fee_state = self.fee_merkle_tree.clone(); + + // Deduct the fee from the paying account. let FeeInfo { account, amount } = fee_info; let mut err = None; - let res = self.fee_merkle_tree.update_with(account, |balance| { + let fee_state = fee_state.persistent_update_with(account, |balance| { let balance = balance.copied(); let Some(updated) = balance.unwrap_or_default().checked_sub(&amount) else { // Return an error without updating the account. @@ -157,17 +172,21 @@ impl ValidatedState { Some(updated) } })?; - // Check if we were unable to do the update because the required Merkle path is missing. - ensure!( - res.expect_not_in_memory().is_err(), - format!("missing account state for {account}") - ); - // Fail if there was an error during `update_with`, otherwise succeed. + + // Fail if there was an error during `persistent_update_with` (e.g. insufficient balance). if let Some(err) = err { - Err(err) - } else { - Ok(()) + return Err(err); } + + // If we successfully deducted the fee from the source account, increment the balance of the + // recipient account. + let fee_state = fee_state.persistent_update_with(recipient, |balance| { + Some(balance.copied().unwrap_or_default() + amount) + })?; + + // If the whole update was successful, update the original state. + self.fee_merkle_tree = fee_state; + Ok(()) } } @@ -202,15 +221,24 @@ pub fn validate_proposal( ) ); + // validate block size and fee + let block_size = VidSchemeType::get_payload_byte_len(vid_common) as u64; anyhow::ensure!( - (VidSchemeType::get_payload_byte_len(vid_common) as u64) - < expected_chain_config.max_block_size(), + block_size < expected_chain_config.max_block_size, anyhow::anyhow!( "Invalid Payload Size: local={:?}, proposal={:?}", expected_chain_config, proposal.chain_config ) ); + anyhow::ensure!( + proposal.fee_info.amount() >= expected_chain_config.base_fee * block_size, + format!( + "insufficient fee: block_size={block_size}, base_fee={:?}, proposed_fee={:?}", + expected_chain_config.base_fee, + proposal.fee_info.amount() + ) + ); // validate height anyhow::ensure!( @@ -253,9 +281,10 @@ fn charge_fee( state: &mut ValidatedState, delta: &mut Delta, fee_info: FeeInfo, + recipient: FeeAccount, ) -> anyhow::Result<()> { - state.charge_fee(fee_info)?; - delta.fees_delta.insert(fee_info.account); + state.charge_fee(fee_info, recipient)?; + delta.fees_delta.extend([fee_info.account, recipient]); Ok(()) } @@ -265,13 +294,18 @@ fn validate_builder_fee(proposed_header: &Header) -> anyhow::Result<()> { let signature = proposed_header .builder_signature .ok_or_else(|| anyhow::anyhow!("Builder signature not found"))?; - let msg = proposed_header.fee_message().context("invalid fee")?; + let fee_amount = proposed_header.fee_info.amount().as_u64().context(format!( + "fee amount out of range: {:?}", + proposed_header.fee_info.amount() + ))?; // verify signature anyhow::ensure!( - proposed_header - .fee_info - .account - .validate_builder_signature(&signature, msg.as_ref()), + proposed_header.fee_info.account.validate_fee_signature( + &signature, + fee_amount, + proposed_header.metadata(), + &proposed_header.payload_commitment() + ), "Invalid Builder Signature" ); @@ -514,7 +548,7 @@ impl SequencerStateDataSource for T where } impl ValidatedState { - async fn apply_header( + pub(crate) async fn apply_header( &self, instance: &NodeState, parent_leaf: &Leaf, @@ -527,11 +561,16 @@ impl ValidatedState { let mut validated_state = self.clone(); - let accounts = std::iter::once(proposed_header.fee_info.account); - - // Find missing state entries + // Find missing fee state entries. We will need to use the builder account which is paying a + // fee and the recipient account which is receiving it, plus any counts receiving deposits + // in this block. let missing_accounts = self.forgotten_accounts( - accounts.chain(l1_deposits.iter().map(|fee_info| fee_info.account)), + [ + proposed_header.fee_info.account, + instance.chain_config().fee_recipient, + ] + .into_iter() + .chain(l1_deposits.iter().map(|fee_info| fee_info.account)), ); let parent_height = parent_leaf.get_height(); @@ -589,7 +628,12 @@ impl ValidatedState { let mut validated_state = apply_proposal(&validated_state, &mut delta, parent_leaf, l1_deposits); - charge_fee(&mut validated_state, &mut delta, proposed_header.fee_info)?; + charge_fee( + &mut validated_state, + &mut delta, + proposed_header.fee_info, + instance.chain_config().fee_recipient, + )?; Ok((validated_state, delta)) } @@ -600,10 +644,13 @@ pub async fn get_l1_deposits( header: &Header, parent_leaf: &Leaf, ) -> Vec { - if let Some(block_info) = header.l1_finalized { + if let (Some(addr), Some(block_info)) = + (instance.chain_config.fee_contract, header.l1_finalized) + { instance .l1_client .get_finalized_deposits( + addr, parent_leaf .get_block_header() .l1_finalized @@ -617,7 +664,7 @@ pub async fn get_l1_deposits( } #[must_use] -pub fn apply_proposal( +fn apply_proposal( validated_state: &ValidatedState, delta: &mut Delta, parent_leaf: &Leaf, @@ -834,6 +881,15 @@ impl FeeInfo { } } +impl From> for FeeInfo { + fn from(fee: BuilderFee) -> Self { + Self { + amount: fee.fee_amount.into(), + account: fee.fee_account, + } + } +} + impl From for FeeInfo { fn from(item: DepositFilter) -> Self { Self { @@ -863,6 +919,7 @@ impl Committable for FeeInfo { Copy, Clone, Debug, + Display, Deserialize, Serialize, PartialEq, @@ -871,9 +928,11 @@ impl Committable for FeeInfo { Ord, Add, Sub, + Mul, From, Into, )] +#[display(fmt = "{_0}")] pub struct FeeAmount(U256); impl_to_fixed_bytes!(FeeAmount, U256); @@ -890,8 +949,16 @@ impl CheckedSub for FeeAmount { } } +impl FromStr for FeeAmount { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + Ok(Self(s.parse()?)) + } +} + impl FeeAmount { - pub(crate) fn as_u64(&self) -> Option { + pub fn as_u64(&self) -> Option { if self.0 <= u64::MAX.into() { Some(self.0.as_u64()) } else { @@ -1163,6 +1230,8 @@ impl FeeAccountProof { mod test { use super::*; use async_compatibility_layer::logging::{setup_backtrace, setup_logging}; + use hotshot_types::vid::vid_scheme; + use jf_vid::VidScheme; #[test] fn test_fee_proofs() { @@ -1202,4 +1271,92 @@ mod test { FeeAccountProof::prove(&tree, account1).unwrap(); FeeAccountProof::prove(&tree, account2).unwrap(); } + + #[test] + fn test_validation_max_block_size() { + setup_logging(); + setup_backtrace(); + + const MAX_BLOCK_SIZE: usize = 10; + let payload = [0; 2 * MAX_BLOCK_SIZE]; + let vid_common = vid_scheme(1).disperse(payload).unwrap().common; + + let state = ValidatedState::default(); + let instance = NodeState::mock().with_chain_config(ChainConfig { + max_block_size: MAX_BLOCK_SIZE as u64, + base_fee: 0.into(), + ..Default::default() + }); + let parent = Leaf::genesis(&instance); + let header = parent.get_block_header(); + + // Validation fails because the proposed block exceeds the maximum block size. + let err = validate_proposal(&state, instance.chain_config, &parent, header, &vid_common) + .unwrap_err(); + tracing::info!(%err, "task failed successfully"); + } + + #[test] + fn test_validation_base_fee() { + setup_logging(); + setup_backtrace(); + + let max_block_size = 10; + let payload = [0; 1]; + let vid_common = vid_scheme(1).disperse(payload).unwrap().common; + + let state = ValidatedState::default(); + let instance = NodeState::mock().with_chain_config(ChainConfig { + base_fee: 1000.into(), // High base fee + max_block_size, + ..Default::default() + }); + let parent = Leaf::genesis(&instance); + let header = parent.get_block_header(); + + // Validation fails because the genesis fee (0) is too low. + let err = validate_proposal(&state, instance.chain_config, &parent, header, &vid_common) + .unwrap_err(); + tracing::info!(%err, "task failed successfully"); + } + + #[test] + fn test_charge_fee() { + setup_logging(); + setup_backtrace(); + + let src = FeeAccount::generated_from_seed_indexed([0; 32], 0).0; + let dst = FeeAccount::generated_from_seed_indexed([0; 32], 1).0; + let amt = FeeAmount::from(1); + + let fee_info = FeeInfo::new(src, amt); + + let new_state = || { + let mut state = ValidatedState::default(); + state.prefund_account(src, amt); + state + }; + + tracing::info!("test successful fee"); + let mut state = new_state(); + state.charge_fee(fee_info, dst).unwrap(); + assert_eq!(state.balance(src), Some(0.into())); + assert_eq!(state.balance(dst), Some(amt)); + + tracing::info!("test insufficient balance"); + state.charge_fee(fee_info, dst).unwrap_err(); + assert_eq!(state.balance(src), Some(0.into())); + assert_eq!(state.balance(dst), Some(amt)); + + tracing::info!("test src not in memory"); + let mut state = new_state(); + state.fee_merkle_tree.forget(src).expect_ok().unwrap(); + state.charge_fee(fee_info, dst).unwrap_err(); + + tracing::info!("test dst not in memory"); + let mut state = new_state(); + state.prefund_account(dst, amt); + state.fee_merkle_tree.forget(dst).expect_ok().unwrap(); + state.charge_fee(fee_info, dst).unwrap_err(); + } } diff --git a/utils/src/deployer.rs b/utils/src/deployer.rs index 204d85196..de576f929 100644 --- a/utils/src/deployer.rs +++ b/utils/src/deployer.rs @@ -35,6 +35,14 @@ pub struct DeployedContracts { /// Use an already-deployed LightClient.sol proxy instead of deploying a new one. #[clap(long, env = Contract::LightClientProxy)] light_client_proxy: Option
, + + /// Use an already-deployed FeeContract.sol instead of deploying a new one. + #[clap(long, env = Contract::FeeContract)] + fee_contract: Option
, + + /// Use an already-deployed FeeContract.sol proxy instead of deploying a new one. + #[clap(long, env = Contract::FeeContractProxy)] + fee_contract_proxy: Option
, } /// An identifier for a particular contract. @@ -50,6 +58,10 @@ pub enum Contract { LightClient, #[display(fmt = "ESPRESSO_SEQUENCER_LIGHT_CLIENT_PROXY_ADDRESS")] LightClientProxy, + #[display(fmt = "ESPRESSO_SEQUENCER_FEE_CONTRACT_ADDRESS")] + FeeContract, + #[display(fmt = "ESPRESSO_SEQUENCER_FEE_CONTRACT_PROXY_ADDRESS")] + FeeContractProxy, } impl From for OsStr { @@ -80,6 +92,12 @@ impl From for Contracts { if let Some(addr) = deployed.light_client_proxy { m.insert(Contract::LightClientProxy, addr); } + if let Some(addr) = deployed.fee_contract { + m.insert(Contract::FeeContract, addr); + } + if let Some(addr) = deployed.fee_contract_proxy { + m.insert(Contract::FeeContractProxy, addr); + } Self(m) } }