diff --git a/.env b/.env index b2e593ae2b..6aad1607de 100644 --- a/.env +++ b/.env @@ -30,10 +30,9 @@ ESPRESSO_SEQUENCER1_API_PORT=24001 ESPRESSO_SEQUENCER2_API_PORT=24002 ESPRESSO_SEQUENCER3_API_PORT=24003 ESPRESSO_SEQUENCER4_API_PORT=24004 -ESPRESSO_SEQUENCER_MAX_BLOCK_SIZE=1mb -ESPRESSO_SEQUENCER_BASE_FEE=1 ESPRESSO_SEQUENCER_URL=http://sequencer0:${ESPRESSO_SEQUENCER_API_PORT} ESPRESSO_SEQUENCER_STORAGE_PATH=/store/sequencer +ESPRESSO_SEQUENCER_GENESIS_FILE=/genesis/demo.toml ESPRESSO_SEQUENCER_L1_PORT=8545 ESPRESSO_SEQUENCER_L1_WS_PORT=8546 ESPRESSO_SEQUENCER_L1_PROVIDER=http://demo-l1-network:${ESPRESSO_SEQUENCER_L1_PORT} @@ -55,7 +54,6 @@ ESPRESSO_DEPLOYER_ACCOUNT_INDEX=9 # Contracts ESPRESSO_SEQUENCER_HOTSHOT_ADDRESS=0xb19b36b1456e65e3a6d514d3f715f204bd59f431 -ESPRESSO_SEQUENCER_FEE_CONTRACT_PROXY_ADDRESS=0xa15bb66138824a1c7167f5e85b957d04dd34e468 ESPRESSO_SEQUENCER_LIGHT_CLIENT_PROXY_ADDRESS=0x0c8e79f3534b00d9a3d4a856b665bf4ebc22f2ba ESPRESSO_SEQUENCER_LIGHTCLIENT_ADDRESS=$ESPRESSO_SEQUENCER_LIGHT_CLIENT_PROXY_ADDRESS @@ -104,6 +102,7 @@ ESPRESSO_BUILDER_INIT_NODE_COUNT=$ESPRESSO_ORCHESTRATOR_NUM_NODES ESPRESSO_BUILDER_BOOTSTRAPPED_VIEW=0 ESPRESSO_BUILDER_WEBSERVER_RESPONSE_TIMEOUT_DURATION=1500ms ESPRESSO_BUILDER_BUFFER_VIEW_NUM_COUNT=50 +ESPRESSO_BUILDER_GENESIS_FILE=$ESPRESSO_SEQUENCER_GENESIS_FILE # Load generator ESPRESSO_SUBMIT_TRANSACTIONS_DELAY=2s diff --git a/builder/src/bin/permissioned-builder.rs b/builder/src/bin/permissioned-builder.rs index 12840b23b3..c35a24e1bc 100644 --- a/builder/src/bin/permissioned-builder.rs +++ b/builder/src/bin/permissioned-builder.rs @@ -10,9 +10,9 @@ use hotshot_types::light_client::StateSignKey; use hotshot_types::signature_key::BLSPrivKey; use hotshot_types::traits::metrics::NoMetrics; use hotshot_types::traits::node_implementation::ConsensusTime; -use sequencer::eth_signature_key::EthKeyPair; use sequencer::persistence::no_storage::NoStorage; -use sequencer::{options::parse_size, BuilderParams, L1Params, NetworkParams}; +use sequencer::{eth_signature_key::EthKeyPair, Genesis}; +use sequencer::{L1Params, NetworkParams}; use snafu::Snafu; use std::net::ToSocketAddrs; use std::num::NonZeroUsize; @@ -21,10 +21,6 @@ use url::Url; #[derive(Parser, Clone, Debug)] pub struct PermissionedBuilderOptions { - /// 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, @@ -80,6 +76,10 @@ pub struct PermissionedBuilderOptions { )] pub webserver_poll_interval: Duration, + /// Path to TOML file containing genesis state. + #[clap(long, name = "GENESIS_FILE", env = "ESPRESSO_BUILDER_GENESIS_FILE")] + pub genesis_file: PathBuf, + /// Path to file containing private keys. /// /// The file should follow the .env format, with two keys: @@ -129,10 +129,6 @@ pub struct PermissionedBuilderOptions { #[clap(long, env = "ESPRESSO_SEQUENCER_STATE_PEERS", value_delimiter = ',')] pub state_peers: Vec, - /// Maximum size in bytes of a block - #[clap(long, env = "ESPRESSO_SEQUENCER_MAX_BLOCK_SIZE", value_parser = parse_size)] - pub max_block_size: u64, - /// Port to run the builder server on. #[clap(short, long, env = "ESPRESSO_BUILDER_SERVER_PORT")] pub port: u16, @@ -174,10 +170,6 @@ pub struct PermissionedBuilderOptions { /// Whether or not we are a DA node. #[clap(long, env = "ESPRESSO_SEQUENCER_IS_DA", action)] pub is_da: bool, - - /// Base Fee for a block - #[clap(long, env = "ESPRESSO_BUILDER_BLOCK_BASE_FEE", default_value = "0")] - base_fee: u64, } #[derive(Clone, Debug, Snafu)] @@ -226,16 +218,11 @@ async fn main() -> anyhow::Result<()> { let l1_params = L1Params { url: opt.l1_provider_url, - finalized_block: None, events_max_block_range: 10000, }; let builder_key_pair = EthKeyPair::from_mnemonic(&opt.eth_mnemonic, opt.eth_account_index)?; - let builder_params = BuilderParams { - prefunded_accounts: vec![], - }; - // Parse supplied Libp2p addresses to their socket form // We expect all nodes to be reachable via IPv4, so we filter out any IPv6 addresses. // Downstream in HotShot we pin the IP address to v4, but this can be fixed in the future. @@ -277,9 +264,9 @@ async fn main() -> anyhow::Result<()> { // it will internally spawn the builder web server let ctx = init_node( + Genesis::from_file(&opt.genesis_file)?, network_params, &NoMetrics, - builder_params, l1_params, builder_server_url.clone(), builder_key_pair, @@ -291,8 +278,6 @@ async fn main() -> anyhow::Result<()> { buffer_view_num_count, opt.is_da, txn_timeout_duration, - opt.base_fee, - opt.max_block_size, ) .await?; diff --git a/builder/src/bin/permissionless-builder.rs b/builder/src/bin/permissionless-builder.rs index 19c4a1e30b..137b5e4e31 100644 --- a/builder/src/bin/permissionless-builder.rs +++ b/builder/src/bin/permissionless-builder.rs @@ -3,13 +3,12 @@ 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::{eth_signature_key::EthKeyPair, options::parse_size, ChainConfig, L1Params}; +use sequencer::{eth_signature_key::EthKeyPair, Genesis, L1Params}; use snafu::Snafu; use std::num::NonZeroUsize; -use std::{str::FromStr, time::Duration}; +use std::{path::PathBuf, str::FromStr, time::Duration}; use url::Url; #[derive(Parser, Clone, Debug)] @@ -42,18 +41,6 @@ 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)] - 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")] port: u16, @@ -87,6 +74,10 @@ struct NonPermissionedBuilderOptions { default_value = "15" )] buffer_view_num_count: usize, + + /// Path to TOML file containing genesis state. + #[clap(long, name = "GENESIS_FILE", env = "ESPRESSO_BUILDER_GENESIS_FILE")] + genesis_file: PathBuf, } #[derive(Clone, Debug, Snafu)] @@ -108,12 +99,12 @@ async fn main() -> anyhow::Result<()> { setup_backtrace(); let opt = NonPermissionedBuilderOptions::parse(); + let genesis = Genesis::from_file(&opt.genesis_file)?; let sequencer_version = SEQUENCER_VERSION; let l1_params = L1Params { url: opt.l1_provider_url, - finalized_block: None, events_max_block_range: 10000, }; @@ -122,14 +113,13 @@ async fn main() -> anyhow::Result<()> { let builder_server_url: Url = format!("http://0.0.0.0:{}", opt.port).parse().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 instance_state = build_instance_state( + genesis.chain_config, + l1_params, + opt.state_peers, + sequencer_version, + ) + .unwrap(); let api_response_timeout_duration = opt.max_api_timeout_duration; diff --git a/builder/src/lib.rs b/builder/src/lib.rs index 6263e4e736..b077884b53 100644 --- a/builder/src/lib.rs +++ b/builder/src/lib.rs @@ -54,7 +54,7 @@ use sequencer::{ state::FeeAccount, state::ValidatedState, state_signature::{static_stake_table_commitment, StateSigner}, - BuilderParams, L1Params, NetworkParams, Node, NodeState, PrivKey, PubKey, SeqTypes, + L1Params, NetworkParams, Node, NodeState, PrivKey, PubKey, SeqTypes, }; use std::{alloc::System, any, fmt::Debug, mem}; use std::{marker::PhantomData, net::IpAddr}; diff --git a/builder/src/non_permissioned.rs b/builder/src/non_permissioned.rs index 86b3877f70..fbe3954bd9 100644 --- a/builder/src/non_permissioned.rs +++ b/builder/src/non_permissioned.rs @@ -35,8 +35,8 @@ use hotshot_types::{ utils::BuilderCommitment, }; use sequencer::{ - catchup::StatePeers, eth_signature_key::EthKeyPair, l1_client::L1Client, BuilderParams, - ChainConfig, L1Params, NetworkParams, NodeState, Payload, PrivKey, PubKey, SeqTypes, + catchup::StatePeers, eth_signature_key::EthKeyPair, l1_client::L1Client, ChainConfig, L1Params, + NetworkParams, NodeState, Payload, PrivKey, PubKey, SeqTypes, }; use hotshot_events_service::{ @@ -59,9 +59,9 @@ pub struct BuilderConfig { } pub fn build_instance_state( + chain_config: ChainConfig, l1_params: L1Params, state_peers: Vec, - chain_config: ChainConfig, _: Ver, ) -> anyhow::Result { let l1_client = L1Client::new(l1_params.url, l1_params.events_max_block_range); @@ -154,7 +154,7 @@ impl BuilderConfig { node_count, maximize_txns_count_timeout_duration, instance_state - .chain_config() + .chain_config .base_fee .as_u64() .context("the base fee exceeds the maximum amount that a builder can pay (defined by u64::MAX)")?, diff --git a/builder/src/permissioned.rs b/builder/src/permissioned.rs index 5bcd7717a3..2c4595b37b 100644 --- a/builder/src/permissioned.rs +++ b/builder/src/permissioned.rs @@ -70,13 +70,14 @@ use sequencer::{catchup::mock::MockStateCatchup, eth_signature_key::EthKeyPair, use sequencer::{ catchup::StatePeers, context::{Consensus, SequencerContext}, + genesis::L1Finalized, l1_client::L1Client, network, persistence::SequencerPersistence, state::FeeAccount, state::ValidatedState, state_signature::{static_stake_table_commitment, StateSigner}, - BuilderParams, L1Params, NetworkParams, Node, NodeState, Payload, PrivKey, PubKey, SeqTypes, + Genesis, L1Params, NetworkParams, Node, NodeState, Payload, PrivKey, PubKey, SeqTypes, }; use std::{alloc::System, any, fmt::Debug, mem}; use std::{marker::PhantomData, net::IpAddr}; @@ -127,9 +128,9 @@ pub struct BuilderContext< #[allow(clippy::too_many_arguments)] pub async fn init_node( + genesis: Genesis, network_params: NetworkParams, metrics: &dyn Metrics, - builder_params: BuilderParams, l1_params: L1Params, hotshot_builder_api_url: Url, eth_key_pair: EthKeyPair, @@ -141,8 +142,6 @@ pub async fn init_node anyhow::Result> { // Orchestrator client let validator_args = ValidatorArgs { @@ -242,22 +241,29 @@ pub async fn init_node Some(b), + Some(L1Finalized::Number { number }) => { + Some(l1_client.wait_for_finalized_block(number).await) + } + None => None, + }; + + let instance_state = NodeState { + chain_config: genesis.chain_config, l1_client, - Arc::new(StatePeers::::from_urls(network_params.state_peers)), - ); + genesis_header: genesis.header, + genesis_state, + l1_genesis, + peers: Arc::new(StatePeers::::from_urls(network_params.state_peers)), + node_id: node_index, + }; let stake_table_commit = static_stake_table_commitment(&config.config.known_nodes_with_stake, STAKE_TABLE_CAPACITY); @@ -441,7 +447,7 @@ impl { pub server: SequencerContext, diff --git a/sequencer/src/block/payload.rs b/sequencer/src/block/payload.rs index 023c9399c4..d6dd371760 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; } @@ -373,7 +373,7 @@ mod test { let n_txs = target_payload_total as u64 / tx_size; let chain_config = ChainConfig { - max_block_size, + max_block_size: max_block_size.into(), ..Default::default() }; diff --git a/sequencer/src/chain_config.rs b/sequencer/src/chain_config.rs index baaa896c4f..5140d6ca56 100644 --- a/sequencer/src/chain_config.rs +++ b/sequencer/src/chain_config.rs @@ -2,69 +2,113 @@ use crate::{ options::parse_size, state::{FeeAccount, FeeAmount}, }; -use clap::Args; use committable::{Commitment, Committable}; -use derive_more::{From, Into}; +use derive_more::{Deref, Display, From, Into}; use ethers::types::{Address, U256}; use itertools::Either; -use sequencer_utils::{deserialize_from_decimal, impl_to_fixed_bytes, serialize_as_decimal}; +use sequencer_utils::{ + impl_serde_from_string_or_integer, impl_to_fixed_bytes, ser::FromStringOrInteger, +}; use serde::{Deserialize, Serialize}; use std::str::FromStr; -#[derive(Default, Hash, Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq, From, Into)] -pub struct ChainId( - #[serde( - serialize_with = "serialize_as_decimal", - deserialize_with = "deserialize_from_decimal" - )] - U256, -); +#[derive(Default, Hash, Copy, Clone, Debug, Display, PartialEq, Eq, From, Into)] +#[display(fmt = "{_0}")] +pub struct ChainId(U256); +impl_serde_from_string_or_integer!(ChainId); impl_to_fixed_bytes!(ChainId, U256); +impl FromStringOrInteger for ChainId { + type Binary = U256; + type Integer = u64; + + fn from_binary(b: Self::Binary) -> anyhow::Result { + Ok(Self(b)) + } + + fn from_integer(i: Self::Integer) -> anyhow::Result { + Ok(i.into()) + } + + fn from_string(s: String) -> anyhow::Result { + if s.starts_with("0x") { + Ok(Self(U256::from_str(&s)?)) + } else { + Ok(Self(U256::from_dec_str(&s)?)) + } + } + + fn to_binary(&self) -> anyhow::Result { + Ok(self.0) + } + + fn to_string(&self) -> anyhow::Result { + Ok(format!("{self}")) + } +} + impl From for ChainId { fn from(id: u64) -> Self { Self(id.into()) } } -impl FromStr for ChainId { - type Err = ::Err; +#[derive(Hash, Copy, Clone, Debug, Default, Display, PartialEq, Eq, From, Into, Deref)] +#[display(fmt = "{_0}")] +pub struct BlockSize(u64); + +impl_serde_from_string_or_integer!(BlockSize); + +impl FromStringOrInteger for BlockSize { + type Binary = u64; + type Integer = u64; + + fn from_binary(b: Self::Binary) -> anyhow::Result { + Ok(Self(b)) + } + + fn from_integer(i: Self::Integer) -> anyhow::Result { + Ok(Self(i)) + } - fn from_str(s: &str) -> Result { - Ok(u64::from_str(s)?.into()) + fn from_string(s: String) -> anyhow::Result { + Ok(parse_size(&s)?.into()) + } + + fn to_binary(&self) -> anyhow::Result { + Ok(self.0) + } + + fn to_string(&self) -> anyhow::Result { + Ok(format!("{self}")) } } /// Global variables for an Espresso blockchain. -#[derive(Args, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ChainConfig { /// Espresso chain ID - #[clap(long, env = "ESPRESSO_SEQUENCER_CHAIN_ID", default_value = "0")] pub chain_id: ChainId, + /// Maximum size in bytes of a block - #[clap(long, env = "ESPRESSO_SEQUENCER_MAX_BLOCK_SIZE", default_value = "30mb", value_parser = parse_size)] - pub max_block_size: u64, + pub max_block_size: BlockSize, + /// Minimum fee in WEI per byte of payload - #[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, } @@ -72,7 +116,7 @@ impl Default for ChainConfig { fn default() -> Self { Self { chain_id: U256::from(35353).into(), // arbitrarily chosen chain ID - max_block_size: 10240, + max_block_size: 10240.into(), base_fee: 0.into(), fee_contract: None, fee_recipient: Default::default(), @@ -88,7 +132,7 @@ impl Committable for ChainConfig { fn commit(&self) -> Commitment { 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) + .u64_field("max_block_size", *self.max_block_size) .fixed_size_field("base_fee", &self.base_fee.to_fixed_bytes()) .fixed_size_field("fee_recipient", &self.fee_recipient.to_fixed_bytes()); let comm = if let Some(addr) = self.fee_contract { @@ -140,6 +184,76 @@ impl From for ResolvableChainConfig { mod tests { use super::*; + #[test] + fn test_chainid_serde_json_as_decimal() { + let id = ChainId::from(123); + let serialized = serde_json::to_string(&id).unwrap(); + + // The value is serialized as a decimal string. + assert_eq!(serialized, "\"123\""); + + // Deserialization produces the original value + let deserialized: ChainId = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, id); + } + + #[test] + fn test_chainid_serde_json_from_hex() { + // For backwards compatibility, chain ID can also be deserialized from a 0x-prefixed hex + // string. + let id: ChainId = serde_json::from_str("\"0x123\"").unwrap(); + assert_eq!(id, ChainId::from(0x123)); + } + + #[test] + fn test_chainid_serde_json_from_number() { + // For convenience, chain ID can also be deserialized from a decimal number. + let id: ChainId = serde_json::from_str("123").unwrap(); + assert_eq!(id, ChainId::from(123)); + } + + #[test] + fn test_chainid_serde_bincode_unchanged() { + // For non-human-readable formats, ChainId just serializes as the underlying U256. + let n = U256::from(123); + let id = ChainId(n); + assert_eq!( + bincode::serialize(&n).unwrap(), + bincode::serialize(&id).unwrap(), + ); + } + + #[test] + fn test_block_size_serde_json_as_decimal() { + let size = BlockSize::from(123); + let serialized = serde_json::to_string(&size).unwrap(); + + // The value is serialized as a decimal string. + assert_eq!(serialized, "\"123\""); + + // Deserialization produces the original value + let deserialized: BlockSize = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, size); + } + + #[test] + fn test_block_size_serde_json_from_number() { + // For backwards compatibility, block size can also be deserialized from a decimal number. + let size: BlockSize = serde_json::from_str("123").unwrap(); + assert_eq!(size, BlockSize::from(123)); + } + + #[test] + fn test_block_size_serde_bincode_unchanged() { + // For non-human-readable formats, BlockSize just serializes as the underlying u64. + let n = 123u64; + let size = BlockSize(n); + assert_eq!( + bincode::serialize(&n).unwrap(), + bincode::serialize(&size).unwrap(), + ); + } + #[test] fn test_chain_config_equality() { let chain_config = ChainConfig::default(); diff --git a/sequencer/src/context.rs b/sequencer/src/context.rs index f2f1ecf2ce..b0fbefc7b8 100644 --- a/sequencer/src/context.rs +++ b/sequencer/src/context.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use async_std::{ sync::{Arc, RwLock}, task::{spawn, JoinHandle}, @@ -72,7 +73,7 @@ impl>, state_relay_server: Option, metrics: &dyn Metrics, - stake_table_capacity: usize, + stake_table_capacity: u64, _: Ver, ) -> anyhow::Result { let pub_key = config.my_own_validator_config.public_key; @@ -107,8 +108,12 @@ impl::new( diff --git a/sequencer/src/genesis.rs b/sequencer/src/genesis.rs new file mode 100644 index 0000000000..858e9e4810 --- /dev/null +++ b/sequencer/src/genesis.rs @@ -0,0 +1,292 @@ +use crate::{ + l1_client::L1BlockInfo, + state::{FeeAccount, FeeAmount}, + ChainConfig, +}; +use anyhow::Context; +use derive_more::{Display, From, Into}; +use sequencer_utils::{impl_serde_from_string_or_integer, ser::FromStringOrInteger}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, path::Path}; +use time::{format_description::well_known::Rfc3339 as TimestampFormat, OffsetDateTime}; + +/// Initial configuration of an Espresso stake table. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct StakeTableConfig { + pub capacity: u64, +} + +/// An L1 block from which an Espresso chain should start syncing. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum L1Finalized { + /// Complete block info. + /// + /// This allows a validator to specify the exact, existing L1 block to start syncing from. A + /// validator that specifies a specific L1 block will not be able to reach consensus with a + /// malicious validator that starts from a different L1 block. + Block(L1BlockInfo), + + /// An L1 block number to sync from. + /// + /// This allows a validator to specify a future L1 block whose hash is not yet known, and start + /// syncing only when a finalized block with the given number becomes available. The configured + /// L1 client will be used to fetch the rest of the block info once available. + Number { number: u64 }, +} + +#[derive(Hash, Copy, Clone, Debug, Display, PartialEq, Eq, From, Into)] +#[display(fmt = "{}", "_0.format(&TimestampFormat).unwrap()")] +pub struct Timestamp(OffsetDateTime); + +impl_serde_from_string_or_integer!(Timestamp); + +impl Default for Timestamp { + fn default() -> Self { + Self::from_integer(0).unwrap() + } +} + +impl Timestamp { + pub fn unix_timestamp(&self) -> u64 { + self.0.unix_timestamp() as u64 + } +} + +impl FromStringOrInteger for Timestamp { + type Binary = u64; + type Integer = u64; + + fn from_binary(b: Self::Binary) -> anyhow::Result { + Self::from_integer(b) + } + + fn from_integer(i: Self::Integer) -> anyhow::Result { + let unix = i.try_into().context("timestamp out of range")?; + Ok(Self( + OffsetDateTime::from_unix_timestamp(unix).context("invalid timestamp")?, + )) + } + + fn from_string(s: String) -> anyhow::Result { + Ok(Self( + OffsetDateTime::parse(&s, &TimestampFormat).context("invalid timestamp")?, + )) + } + + fn to_binary(&self) -> anyhow::Result { + Ok(self.unix_timestamp()) + } + + fn to_string(&self) -> anyhow::Result { + Ok(format!("{self}")) + } +} + +/// Information about the genesis state which feeds into the genesis block header. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct GenesisHeader { + pub timestamp: Timestamp, +} + +/// Genesis of an Espresso chain. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Genesis { + pub chain_config: ChainConfig, + pub stake_table: StakeTableConfig, + #[serde(default)] + pub accounts: HashMap, + pub l1_finalized: Option, + pub header: GenesisHeader, +} + +impl Genesis { + pub fn to_file(&self, path: impl AsRef) -> anyhow::Result<()> { + let toml = toml::to_string_pretty(self)?; + std::fs::write(path, toml.as_bytes())?; + Ok(()) + } + + pub fn from_file(path: impl AsRef) -> anyhow::Result { + let path = path.as_ref(); + let bytes = std::fs::read(path).context(format!("genesis file {}", path.display()))?; + let text = std::str::from_utf8(&bytes).context("genesis file must be UTF-8")?; + toml::from_str(text).context("malformed genesis file") + } +} + +#[cfg(test)] +mod test { + use super::*; + use ethers::prelude::{Address, H160, H256}; + use toml::toml; + + #[test] + fn test_genesis_from_toml_with_optional_fields() { + let toml = toml! { + [stake_table] + capacity = 10 + + [chain_config] + chain_id = 12345 + max_block_size = 30000 + base_fee = 1 + fee_recipient = "0x0000000000000000000000000000000000000000" + fee_contract = "0x0000000000000000000000000000000000000000" + + [header] + timestamp = 123456 + + [accounts] + "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f" = 100000 + "0x0000000000000000000000000000000000000000" = 42 + + [l1_finalized] + number = 64 + timestamp = "0x123def" + hash = "0x80f5dd11f2bdda2814cb1ad94ef30a47de02cf28ad68c89e104c00c4e51bb7a5" + } + .to_string(); + + let genesis: Genesis = toml::from_str(&toml).unwrap_or_else(|err| panic!("{err:#}")); + assert_eq!(genesis.stake_table, StakeTableConfig { capacity: 10 }); + assert_eq!( + genesis.chain_config, + ChainConfig { + chain_id: 12345.into(), + max_block_size: 30000.into(), + base_fee: 1.into(), + fee_recipient: FeeAccount::default(), + fee_contract: Some(Address::default()) + } + ); + assert_eq!( + genesis.header, + GenesisHeader { + timestamp: Timestamp::from_integer(123456).unwrap(), + } + ); + assert_eq!( + genesis.accounts, + [ + ( + FeeAccount::from(H160([ + 0x23, 0x61, 0x8e, 0x81, 0xe3, 0xf5, 0xcd, 0xf7, 0xf5, 0x4c, 0x3d, 0x65, + 0xf7, 0xfb, 0xc0, 0xab, 0xf5, 0xb2, 0x1e, 0x8f + ])), + 100000.into() + ), + (FeeAccount::default(), 42.into()) + ] + .into_iter() + .collect::>() + ); + assert_eq!( + genesis.l1_finalized, + Some(L1Finalized::Block(L1BlockInfo { + number: 64, + timestamp: 0x123def.into(), + hash: H256([ + 0x80, 0xf5, 0xdd, 0x11, 0xf2, 0xbd, 0xda, 0x28, 0x14, 0xcb, 0x1a, 0xd9, 0x4e, + 0xf3, 0x0a, 0x47, 0xde, 0x02, 0xcf, 0x28, 0xad, 0x68, 0xc8, 0x9e, 0x10, 0x4c, + 0x00, 0xc4, 0xe5, 0x1b, 0xb7, 0xa5 + ]) + })) + ); + } + + #[test] + fn test_genesis_from_toml_without_optional_fields() { + let toml = toml! { + [stake_table] + capacity = 10 + + [chain_config] + chain_id = 12345 + max_block_size = 30000 + base_fee = 1 + fee_recipient = "0x0000000000000000000000000000000000000000" + + [header] + timestamp = 123456 + } + .to_string(); + + let genesis: Genesis = toml::from_str(&toml).unwrap_or_else(|err| panic!("{err:#}")); + assert_eq!(genesis.stake_table, StakeTableConfig { capacity: 10 }); + assert_eq!( + genesis.chain_config, + ChainConfig { + chain_id: 12345.into(), + max_block_size: 30000.into(), + base_fee: 1.into(), + fee_recipient: FeeAccount::default(), + fee_contract: None, + } + ); + assert_eq!( + genesis.header, + GenesisHeader { + timestamp: Timestamp::from_integer(123456).unwrap(), + } + ); + assert_eq!(genesis.accounts, HashMap::default()); + assert_eq!(genesis.l1_finalized, None); + } + + #[test] + fn test_genesis_l1_finalized_number_only() { + let toml = toml! { + [stake_table] + capacity = 10 + + [chain_config] + chain_id = 12345 + max_block_size = 30000 + base_fee = 1 + fee_recipient = "0x0000000000000000000000000000000000000000" + + [header] + timestamp = 123456 + + [l1_finalized] + number = 42 + } + .to_string(); + + let genesis: Genesis = toml::from_str(&toml).unwrap_or_else(|err| panic!("{err:#}")); + assert_eq!( + genesis.l1_finalized, + Some(L1Finalized::Number { number: 42 }) + ); + } + + #[test] + fn test_genesis_from_toml_units() { + let toml = toml! { + [stake_table] + capacity = 10 + + [chain_config] + chain_id = 12345 + max_block_size = "30mb" + base_fee = "1 gwei" + fee_recipient = "0x0000000000000000000000000000000000000000" + + [header] + timestamp = "2024-05-16T11:20:28-04:00" + } + .to_string(); + + let genesis: Genesis = toml::from_str(&toml).unwrap_or_else(|err| panic!("{err:#}")); + assert_eq!(genesis.stake_table, StakeTableConfig { capacity: 10 }); + assert_eq!(*genesis.chain_config.max_block_size, 30000000); + assert_eq!(genesis.chain_config.base_fee, 1_000_000_000.into()); + assert_eq!( + genesis.header, + GenesisHeader { + timestamp: Timestamp::from_integer(1715872828).unwrap(), + } + ) + } +} diff --git a/sequencer/src/header.rs b/sequencer/src/header.rs index cba5423360..b8e0f4f148 100644 --- a/sequencer/src/header.rs +++ b/sequencer/src/header.rs @@ -295,7 +295,7 @@ impl BlockHeader for Header { let mut validated_state = parent_state.clone(); // Fetch the latest L1 snapshot. - let l1_snapshot = instance_state.l1_client().snapshot().await; + 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(addr), Some(block_info)) = (chain_config.fee_contract, l1_snapshot.finalized) @@ -394,9 +394,13 @@ impl BlockHeader for Header { // timestamps or L1 values. chain_config: instance_state.chain_config.into(), height: 0, - timestamp: 0, - l1_head: 0, + timestamp: instance_state.genesis_header.timestamp.unix_timestamp(), l1_finalized: instance_state.l1_genesis, + // Make sure the L1 head is not behind the finalized block. + l1_head: instance_state + .l1_genesis + .map(|block| block.number) + .unwrap_or_default(), payload_commitment, builder_commitment, ns_table, diff --git a/sequencer/src/l1_client.rs b/sequencer/src/l1_client.rs index d64de7f3c9..f88d7a5443 100644 --- a/sequencer/src/l1_client.rs +++ b/sequencer/src/l1_client.rs @@ -318,7 +318,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(), 1)); - let version = state.l1_client().provider.client_version().await.unwrap(); + let version = state.l1_client.provider.client_version().await.unwrap(); assert_eq!("anvil/v0.2.0", version); // compare response of underlying provider w/ `get_block_number` diff --git a/sequencer/src/lib.rs b/sequencer/src/lib.rs index 5a23a99bad..a2b2343322 100644 --- a/sequencer/src/lib.rs +++ b/sequencer/src/lib.rs @@ -4,6 +4,7 @@ pub mod catchup; mod chain_config; pub mod context; pub mod eth_signature_key; +pub mod genesis; mod header; pub mod hotshot_commitment; pub mod options; @@ -18,7 +19,8 @@ use async_trait::async_trait; use block::entry::TxTableEntryWord; use catchup::{StateCatchup, StatePeers}; use context::SequencerContext; -use ethers::types::{Address, U256}; +use ethers::types::U256; +use genesis::{GenesisHeader, L1Finalized}; // Should move `STAKE_TABLE_CAPACITY` in the sequencer repo when we have variate stake table support @@ -80,6 +82,7 @@ use hotshot::traits::implementations::{CombinedNetworks, Libp2pNetwork}; pub use block::payload::Payload; pub use chain_config::ChainConfig; +pub use genesis::Genesis; pub use header::Header; pub use l1_client::L1BlockInfo; pub use options::Options; @@ -160,12 +163,13 @@ impl Storage for Arc> { #[derive(Debug, Clone)] pub struct NodeState { - node_id: u64, - chain_config: ChainConfig, - l1_client: L1Client, - peers: Arc, - genesis_state: ValidatedState, - l1_genesis: Option, + pub node_id: u64, + pub chain_config: ChainConfig, + pub l1_client: L1Client, + pub peers: Arc, + pub genesis_header: GenesisHeader, + pub genesis_state: ValidatedState, + pub l1_genesis: Option, } impl NodeState { @@ -180,6 +184,7 @@ impl NodeState { chain_config, l1_client, peers: Arc::new(catchup), + genesis_header: Default::default(), genesis_state: Default::default(), l1_genesis: None, } @@ -195,10 +200,6 @@ impl NodeState { ) } - 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 @@ -213,10 +214,6 @@ impl NodeState { self.chain_config = cfg; self } - - fn l1_client(&self) -> &L1Client { - &self.l1_client - } } // This allows us to turn on `Default` on InstanceState trait @@ -287,27 +284,18 @@ pub struct NetworkParams { pub libp2p_bind_address: SocketAddr, } -#[derive(Clone, Debug)] -pub struct BuilderParams { - pub prefunded_accounts: Vec
, -} - pub struct L1Params { pub url: Url, - pub finalized_block: Option, pub events_max_block_range: u64, } -#[allow(clippy::too_many_arguments)] pub async fn init_node( + genesis: Genesis, network_params: NetworkParams, metrics: &dyn Metrics, persistence_opt: P, - builder_params: BuilderParams, l1_params: L1Params, - stake_table_capacity: usize, bind_version: Ver, - chain_config: ChainConfig, is_da: bool, ) -> anyhow::Result> { // Expose git information via status API. @@ -456,20 +444,23 @@ pub async fn init_node( let _ = NetworkingMetricsValue::new(metrics); let mut genesis_state = ValidatedState::default(); - for address in builder_params.prefunded_accounts { - tracing::info!("Prefunding account {:?} for demo", address); - genesis_state.prefund_account(address.into(), U256::max_value().into()); + for (address, amount) in genesis.accounts { + tracing::info!(%address, %amount, "Prefunding account for demo"); + genesis_state.prefund_account(address, amount); } 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.wait_for_finalized_block(block).await), + let l1_genesis = match genesis.l1_finalized { + Some(L1Finalized::Block(b)) => Some(b), + Some(L1Finalized::Number { number }) => { + Some(l1_client.wait_for_finalized_block(number).await) + } None => None, }; - let instance_state = NodeState { - chain_config, + chain_config: genesis.chain_config, l1_client, + genesis_header: genesis.header, genesis_state, l1_genesis, peers: catchup::local_and_remote( @@ -487,7 +478,7 @@ pub async fn init_node( networks, Some(network_params.state_relay_server_url), metrics, - stake_table_capacity, + genesis.stake_table.capacity, bind_version, ) .await?; @@ -534,7 +525,7 @@ pub mod testing { use portpicker::pick_unused_port; use std::time::Duration; - const STAKE_TABLE_CAPACITY_FOR_TEST: usize = 10; + const STAKE_TABLE_CAPACITY_FOR_TEST: u64 = 10; pub async fn run_test_builder() -> (Option>>, Url) { >::start( @@ -658,7 +649,7 @@ pub mod testing { persistence_opt: P, catchup: impl StateCatchup + 'static, metrics: &dyn Metrics, - stake_table_capacity: usize, + stake_table_capacity: u64, bind_version: Ver, ) -> SequencerContext { let mut config = self.config.clone(); diff --git a/sequencer/src/main.rs b/sequencer/src/main.rs index 3ade483fa0..dc5b422597 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, L1Params, NetworkParams, + persistence, Genesis, L1Params, NetworkParams, }; use vbs::version::StaticVersionType; @@ -48,16 +48,14 @@ async fn init_with_storage( where S: DataSourceOptions, { + let genesis = Genesis::from_file(&opt.genesis_file)?; + tracing::info!(?genesis, "genesis"); + let (private_staking_key, private_state_key) = opt.private_keys()?; - let stake_table_capacity = opt.stake_table_capacity; let l1_params = L1Params { url: opt.l1_provider_url, - finalized_block: opt.l1_genesis, events_max_block_range: opt.l1_events_max_block_range, }; - let builder_params = BuilderParams { - prefunded_accounts: opt.prefunded_builder_accounts, - }; // Parse supplied Libp2p addresses to their socket form // We expect all nodes to be reachable via IPv4, so we filter out any IPv6 addresses. @@ -123,14 +121,12 @@ where move |metrics| { async move { init_node( + genesis, network_params, &*metrics, storage_opt, - builder_params, l1_params, - stake_table_capacity, bind_version, - opt.chain_config, opt.is_da, ) .await @@ -144,14 +140,12 @@ where } None => { init_node( + genesis, network_params, &NoMetrics, storage_opt, - builder_params, l1_params, - stake_table_capacity, bind_version, - opt.chain_config, opt.is_da, ) .await? @@ -174,6 +168,7 @@ mod test { use portpicker::pick_unused_port; use sequencer::{ api::options::{Http, Status}, + genesis::StakeTableConfig, persistence::fs, PubKey, }; @@ -191,6 +186,17 @@ mod test { let port = pick_unused_port().unwrap(); let tmp = TempDir::new().unwrap(); + + let genesis_file = tmp.path().join("genesis.toml"); + let genesis = Genesis { + chain_config: Default::default(), + stake_table: StakeTableConfig { capacity: 10 }, + accounts: Default::default(), + l1_finalized: Default::default(), + header: Default::default(), + }; + genesis.to_file(&genesis_file).unwrap(); + let modules = Modules { http: Some(Http { port }), status: Some(Status), @@ -202,6 +208,8 @@ mod test { &priv_key.to_string(), "--private-state-key", &state_key.sign_key_ref().to_string(), + "--genesis-file", + &genesis_file.display().to_string(), ]); // Start the sequencer in a background task. This process will not complete, because it will diff --git a/sequencer/src/options.rs b/sequencer/src/options.rs index 8c974a5a15..1a5b83e2ec 100644 --- a/sequencer/src/options.rs +++ b/sequencer/src/options.rs @@ -1,4 +1,4 @@ -use crate::{api, persistence, ChainConfig}; +use crate::{api, persistence}; use anyhow::{bail, Context}; use bytesize::ByteSize; use clap::{error::ErrorKind, Args, FromArgMatches, Parser}; @@ -6,8 +6,6 @@ use cld::ClDuration; use core::fmt::Display; use derivative::Derivative; use derive_more::From; -use ethers::types::Address; -use hotshot_stake_table::config::STAKE_TABLE_CAPACITY; use hotshot_types::light_client::StateSignKey; use hotshot_types::signature_key::BLSPrivKey; use snafu::Snafu; @@ -89,6 +87,15 @@ pub struct Options { #[derivative(Debug(format_with = "Display::fmt"))] pub state_relay_server_url: Url, + /// Path to TOML file containing genesis state. + #[clap( + long, + name = "GENESIS_FILE", + env = "ESPRESSO_SEQUENCER_GENESIS_FILE", + default_value = "/genesis/demo.toml" + )] + pub genesis_file: PathBuf, + /// Path to file containing private keys. /// /// The file should follow the .env format, with two keys: @@ -137,16 +144,6 @@ pub struct Options { #[clap(raw = true)] modules: Vec, - /// Prefunded the builder accounts. Use for demo purposes only. - /// - /// Comma-separated list of Ethereum addresses. - #[clap( - long, - env = "ESPRESSO_SEQUENCER_PREFUNDED_BUILDER_ACCOUNTS", - value_delimiter = ',' - )] - pub prefunded_builder_accounts: Vec
, - /// Url we will use for RPC communication with L1. #[clap( long, @@ -156,10 +153,6 @@ pub struct Options { #[derivative(Debug(format_with = "Display::fmt"))] pub l1_provider_url: Url, - /// L1 block number from which to start the Espresso chain. - #[clap(long, env = "ESPRESSO_SEQUENCER_L1_GENESIS")] - pub l1_genesis: Option, - /// Maximum number of L1 blocks that can be scanned for events in a single query. #[clap( long, @@ -176,13 +169,6 @@ pub struct Options { #[clap(long, env = "ESPRESSO_SEQUENCER_STATE_PEERS", value_delimiter = ',')] #[derivative(Debug(format_with = "fmt_urls"))] pub state_peers: Vec, - - /// Stake table capacity for the prover circuit - #[clap(long, env = "ESPRESSO_SEQUENCER_STAKE_TABLE_CAPACITY", default_value_t = STAKE_TABLE_CAPACITY)] - pub stake_table_capacity: usize, - - #[clap(flatten)] - pub chain_config: ChainConfig, } impl Options { diff --git a/sequencer/src/reference_tests.rs b/sequencer/src/reference_tests.rs index f29af5ce5a..4d60dc5f8e 100644 --- a/sequencer/src/reference_tests.rs +++ b/sequencer/src/reference_tests.rs @@ -63,7 +63,7 @@ const REFERENCE_L1_BLOCK_COMMITMENT: &str = "L1BLOCK~4HpzluLK2Isz3RdPNvNrDAyQcWO fn reference_chain_config() -> ChainConfig { ChainConfig { chain_id: 0x8a19.into(), - max_block_size: 10240, + max_block_size: 10240.into(), base_fee: 0.into(), fee_contract: Some(Default::default()), fee_recipient: Default::default(), diff --git a/sequencer/src/state.rs b/sequencer/src/state.rs index d6ccc42e1e..118863cc0f 100644 --- a/sequencer/src/state.rs +++ b/sequencer/src/state.rs @@ -12,7 +12,11 @@ use committable::{Commitment, Committable, RawCommitmentBuilder}; use contract_bindings::fee_contract::DepositFilter; use core::fmt::Debug; use derive_more::{Add, Display, From, Into, Mul, Sub}; -use ethers::{abi::Address, types::U256}; +use ethers::{ + abi::Address, + types::U256, + utils::{parse_units, ParseUnits}, +}; use futures::future::Future; use hotshot::traits::ValidatedState as HotShotState; use hotshot_query_service::{ @@ -42,7 +46,9 @@ use jf_merkle_tree::{ }; use jf_vid::VidScheme; use num_traits::CheckedSub; -use sequencer_utils::{deserialize_from_decimal, impl_to_fixed_bytes, serialize_as_decimal}; +use sequencer_utils::{ + impl_serde_from_string_or_integer, impl_to_fixed_bytes, ser::FromStringOrInteger, +}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::time::Duration; @@ -226,7 +232,7 @@ pub fn validate_proposal( // validate block size and fee let block_size = VidSchemeType::get_payload_byte_len(vid_common) as u64; anyhow::ensure!( - block_size < expected_chain_config.max_block_size, + block_size < *expected_chain_config.max_block_size, anyhow::anyhow!( "Invalid Payload Size: local={:?}, proposal={:?}", expected_chain_config, @@ -569,7 +575,7 @@ impl ValidatedState { let missing_accounts = self.forgotten_accounts( [ proposed_header.fee_info.account, - instance.chain_config().fee_recipient, + instance.chain_config.fee_recipient, ] .into_iter() .chain(l1_deposits.iter().map(|fee_info| fee_info.account)), @@ -634,7 +640,7 @@ impl ValidatedState { &mut validated_state, &mut delta, proposed_header.fee_info, - instance.chain_config().fee_recipient, + instance.chain_config.fee_recipient, )?; Ok((validated_state, delta)) @@ -923,8 +929,6 @@ impl Committable for FeeInfo { Clone, Debug, Display, - Deserialize, - Serialize, PartialEq, Eq, PartialOrd, @@ -936,14 +940,9 @@ impl Committable for FeeInfo { Into, )] #[display(fmt = "{_0}")] -pub struct FeeAmount( - #[serde( - serialize_with = "serialize_as_decimal", - deserialize_with = "deserialize_from_decimal" - )] - U256, -); +pub struct FeeAmount(U256); +impl_serde_from_string_or_integer!(FeeAmount); impl_to_fixed_bytes!(FeeAmount, U256); impl From for FeeAmount { @@ -972,6 +971,44 @@ impl FromStr for FeeAmount { } } +impl FromStringOrInteger for FeeAmount { + type Binary = U256; + type Integer = u64; + + fn from_binary(b: Self::Binary) -> anyhow::Result { + Ok(Self(b)) + } + + fn from_integer(i: Self::Integer) -> anyhow::Result { + Ok(i.into()) + } + + fn from_string(s: String) -> anyhow::Result { + // For backwards compatibility, we have an ad hoc parser for WEI amounts represented as hex + // strings. + if let Some(s) = s.strip_prefix("0x") { + return Ok(Self(s.parse()?)); + } + + // Strip an optional non-numeric suffix, which will be interpreted as a unit. + let (base, unit) = s + .split_once(char::is_whitespace) + .unwrap_or((s.as_str(), "wei")); + match parse_units(base, unit)? { + ParseUnits::U256(n) => Ok(Self(n)), + ParseUnits::I256(_) => bail!("amount cannot be negative"), + } + } + + fn to_binary(&self) -> anyhow::Result { + Ok(self.0) + } + + fn to_string(&self) -> anyhow::Result { + Ok(format!("{self}")) + } +} + impl FeeAmount { pub fn as_u64(&self) -> Option { if self.0 <= u64::MAX.into() { @@ -1298,7 +1335,7 @@ mod test { let state = ValidatedState::default(); let instance = NodeState::mock().with_chain_config(ChainConfig { - max_block_size: MAX_BLOCK_SIZE as u64, + max_block_size: (MAX_BLOCK_SIZE as u64).into(), base_fee: 0.into(), ..Default::default() }); @@ -1323,7 +1360,7 @@ mod test { let state = ValidatedState::default(); let instance = NodeState::mock().with_chain_config(ChainConfig { base_fee: 1000.into(), // High base fee - max_block_size, + max_block_size: max_block_size.into(), ..Default::default() }); let parent = Leaf::genesis(&instance); @@ -1374,4 +1411,55 @@ mod test { state.fee_merkle_tree.forget(dst).expect_ok().unwrap(); state.charge_fee(fee_info, dst).unwrap_err(); } + + #[test] + fn test_fee_amount_serde_json_as_decimal() { + let amt = FeeAmount::from(123); + let serialized = serde_json::to_string(&amt).unwrap(); + + // The value is serialized as a decimal string. + assert_eq!(serialized, "\"123\""); + + // Deserialization produces the original value + let deserialized: FeeAmount = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, amt); + } + + #[test] + fn test_fee_amount_from_units() { + for (unit, multiplier) in [ + ("wei", 1), + ("gwei", 1_000_000_000), + ("eth", 1_000_000_000_000_000_000), + ] { + let amt: FeeAmount = serde_json::from_str(&format!("\"1 {unit}\"")).unwrap(); + assert_eq!(amt, multiplier.into()); + } + } + + #[test] + fn test_fee_amount_serde_json_from_hex() { + // For backwards compatibility, fee amounts can also be deserialized from a 0x-prefixed hex + // string. + let amt: FeeAmount = serde_json::from_str("\"0x123\"").unwrap(); + assert_eq!(amt, FeeAmount::from(0x123)); + } + + #[test] + fn test_fee_amount_serde_json_from_number() { + // For convenience, fee amounts can also be deserialized from a JSON number. + let amt: FeeAmount = serde_json::from_str("123").unwrap(); + assert_eq!(amt, FeeAmount::from(123)); + } + + #[test] + fn test_fee_amount_serde_bincode_unchanged() { + // For non-human-readable formats, FeeAmount just serializes as the underlying U256. + let n = U256::from(123); + let amt = FeeAmount(n); + assert_eq!( + bincode::serialize(&n).unwrap(), + bincode::serialize(&amt).unwrap(), + ); + } } diff --git a/utils/src/lib.rs b/utils/src/lib.rs index f224860e4f..672c3b8cf9 100644 --- a/utils/src/lib.rs +++ b/utils/src/lib.rs @@ -22,6 +22,7 @@ use tempfile::TempDir; use url::Url; pub mod deployer; +pub mod ser; pub mod test_utils; pub type Signer = SignerMiddleware, LocalWallet>; diff --git a/utils/src/ser.rs b/utils/src/ser.rs new file mode 100644 index 0000000000..63c4a728a8 --- /dev/null +++ b/utils/src/ser.rs @@ -0,0 +1,89 @@ +use serde::{ + de::{DeserializeOwned, Deserializer, Error as _}, + ser::{Error as _, Serializer}, + Deserialize, Serialize, +}; + +/// Types which can be deserialized from either integers or strings. +/// +/// Some types can be represented as an integer or a string in human-readable formats like JSON or +/// TOML. For example, 1 GWEI might be represented by the integer `1000000000` or the string `"1 +/// gwei"`. Such types can implement `FromStringOrInteger` and then use [`impl_string_or_integer`] +/// to derive this user-friendly serialization. +/// +/// These types are assumed to have an efficient representation as an integral type in Rust -- +/// [`Self::Binary`] -- and will be serialized to and from this type when using a non-human-readable +/// encoding. With human readable encodings, serialization is always to a string. +pub trait FromStringOrInteger: Sized { + type Binary: Serialize + DeserializeOwned; + type Integer: Serialize + DeserializeOwned; + + fn from_binary(b: Self::Binary) -> anyhow::Result; + fn from_string(s: String) -> anyhow::Result; + fn from_integer(i: Self::Integer) -> anyhow::Result; + + fn to_binary(&self) -> anyhow::Result; + fn to_string(&self) -> anyhow::Result; +} + +/// Deserialize a type from either a string or integer in human-readable encodings. +/// +/// This macro implements serde `Serialize` and `DeserializeOwned` traits with a friendly +/// deserialization mechanism that can handle strings and integers when using human-readable +/// formats. It works with any [`FromStringOrInteger`] type. +#[macro_export] +macro_rules! impl_serde_from_string_or_integer { + ($t:ty) => { + impl serde::Serialize for $t { + fn serialize(&self, s: S) -> Result { + $crate::ser::string_or_integer::serialize(self, s) + } + } + + impl<'de> serde::Deserialize<'de> for $t { + fn deserialize>(d: D) -> Result { + $crate::ser::string_or_integer::deserialize(d) + } + } + }; +} +pub use crate::impl_serde_from_string_or_integer; + +/// Deserialize a type from either a string or integer in human-readable encodings. +/// +/// This serialization module can be used with any [`FromStringOrInteger`] type. It is usually used +/// only indirectly by the expansion of the [`impl_string_or_integer`] macro. +pub mod string_or_integer { + use super::*; + + #[derive(Debug, Deserialize)] + #[serde(untagged)] + enum StringOrInteger { + String(String), + Integer(I), + } + + pub fn serialize( + t: &T, + s: S, + ) -> Result { + if s.is_human_readable() { + t.to_string().map_err(S::Error::custom)?.serialize(s) + } else { + t.to_binary().map_err(S::Error::custom)?.serialize(s) + } + } + + pub fn deserialize<'a, T: FromStringOrInteger, D: Deserializer<'a>>( + d: D, + ) -> Result { + if d.is_human_readable() { + match StringOrInteger::deserialize(d)? { + StringOrInteger::String(s) => T::from_string(s).map_err(D::Error::custom), + StringOrInteger::Integer(i) => T::from_integer(i).map_err(D::Error::custom), + } + } else { + T::from_binary(T::Binary::deserialize(d)?).map_err(D::Error::custom) + } + } +}