diff --git a/sequencer/src/auction.rs b/sequencer/src/auction.rs new file mode 100644 index 000000000..65ea4c55b --- /dev/null +++ b/sequencer/src/auction.rs @@ -0,0 +1,313 @@ +use crate::{ + eth_signature_key::{EthKeyPair, SigningError}, + state::{FeeAccount, FeeAmount, FeeError, FeeInfo}, + NamespaceId, ValidatedState, +}; +use committable::{Commitment, Committable}; +use ethers::types::Signature; +use hotshot_types::{ + data::ViewNumber, + traits::{ + auction_results_provider::HasUrl, node_implementation::ConsensusTime, + signature_key::BuilderSignatureKey, + }, +}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, Hash)] +/// Wrapper enum for Full Network Transactions. Each transaction type +/// will be a variant of this enum. +pub enum FullNetworkTx { + Bid(BidTx), +} + +impl FullNetworkTx { + pub fn execute( + &self, + state: &mut ValidatedState, + ) -> Result<(), (ExecutionError, FullNetworkTx)> { + match self { + Self::Bid(bid) => bid.execute(state), + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, Hash)] +pub struct BidTx { + body: BidTxBody, + signature: Signature, +} + +/// A transaction to bid for the sequencing rights of a namespace +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, Hash)] +pub struct BidTxBody { + /// Account responsible for the signature + account: FeeAccount, + /// Fee to be sequenced in the network. Different than the bid_amount fee + // FULL_NETWORK_GAS * MINIMUM_GAS_PRICE + gas_price: FeeAmount, + /// The bid amount designated in Wei. This is different than + /// the sequencing fee (gas price) for this transaction + bid_amount: FeeAmount, + // TODO I think this will end up being a `FeeAccount` + /// The public key of this sequencer + public_key: FeeAccount, + /// The URL the HotShot leader will use to request a bundle + /// from this sequencer if they win the auction + url: Url, + /// The slot this bid is for + view: ViewNumber, + /// The set of namespace ids the sequencer is bidding for + namespaces: Vec, +} + +// TODO consider a committable derive macro +impl Committable for BidTxBody { + fn tag() -> String { + "BID_TX".to_string() + } + + fn commit(&self) -> Commitment { + let comm = committable::RawCommitmentBuilder::new(&Self::tag()) + .fixed_size_field("account", &self.account.to_fixed_bytes()) + .fixed_size_field("gas_price", &self.gas_price.to_fixed_bytes()) + .fixed_size_field("bid_amount", &self.bid_amount.to_fixed_bytes()) + .var_size_field("url", self.url.as_str().as_ref()) + .u64_field("view", self.view.u64()) + .var_size_field("namespaces", &bincode::serialize(&self.namespaces).unwrap()); + comm.finalize() + } +} + +impl BidTxBody { + pub fn new( + account: FeeAccount, + bid: FeeAmount, + view: ViewNumber, + namespaces: Vec, + ) -> Self { + Self { + account, + bid_amount: bid, + view, + namespaces, + ..Self::default() + } + } + + /// Sign `BidTxBody` and return the signature. + pub fn sign(&self, key: &EthKeyPair) -> Result { + FeeAccount::sign_builder_message(key, self.commit().as_ref()) + } + /// Sign Body and return a `BidTx` + pub fn signed(self, key: &EthKeyPair) -> Result { + let signature = self.sign(key)?; + let bid = BidTx { + body: self, + signature, + }; + Ok(bid) + } + + /// Get account submitting the bid + pub fn account(&self) -> FeeAccount { + self.account + } + /// Get amount of bid + pub fn amount(&self) -> FeeAmount { + self.bid_amount + } + /// Update `url` field on a previously instantiated `BidTxBody`. + pub fn with_url(self, url: Url) -> Self { + Self { url, ..self } + } +} + +impl Default for BidTxBody { + fn default() -> Self { + let key = FeeAccount::test_key_pair(); + let nsid = NamespaceId::from(999); + Self { + // TODO url will be builder_url, needs to be passed in from somewhere + url: Url::from_str("https://sequencer:3939").unwrap(), + account: key.fee_account(), + public_key: FeeAccount::default(), + gas_price: FeeAmount::default(), + bid_amount: FeeAmount::default(), + view: ViewNumber::genesis(), + namespaces: vec![nsid], + } + } +} +impl Default for BidTx { + fn default() -> Self { + let body = BidTxBody::default(); + let key = FeeAccount::test_key_pair(); + let signature = FeeAccount::sign_builder_message(&key, body.commit().as_ref()).unwrap(); + Self { signature, body } + } +} + +#[derive(Error, Debug, Eq, PartialEq)] +/// Failure cases of transaction execution +pub enum ExecutionError { + #[error("Invalid Signature")] + InvalidSignature, + #[error("Invalid Phase")] + InvalidPhase, + #[error("FeeError: {0}")] + FeeError(FeeError), + #[error("Could not resolve `ChainConfig`")] + UnresolvableChainConfig, +} + +impl From for ExecutionError { + fn from(e: FeeError) -> Self { + Self::FeeError(e) + } +} + +// TODO consider moving common functionality to trait. +impl BidTx { + /// Executes `BidTx`. + /// * verify signature + /// * charge fee + // The rational behind the `Err` is to provide not only what + // failed, but for which variant. The entire Tx is probably + // overkill, but we can narrow down how much we want to know about + // Failed Tx in the future. Maybe we just want its name. + pub fn execute( + &self, + state: &mut ValidatedState, + ) -> Result<(), (ExecutionError, FullNetworkTx)> { + self.verify() + .map_err(|e| (e, FullNetworkTx::Bid(self.clone())))?; + + // In JIT sequencer only receives winning bids. In AOT all + // bids are charged as received (losing bids are refunded). In + // any case we can charge the bids and gas during execution. + self.charge(state) + .map_err(|e| (e, FullNetworkTx::Bid(self.clone())))?; + + // TODO what do we return in good result? + Ok(()) + } + /// Charge Bid. Only winning bids are charged in JIT (I think). + fn charge(&self, state: &mut ValidatedState) -> Result<(), ExecutionError> { + // As the code is currently organized, I think chain_config + // will always be resolved here. But let's guard against the + // error in case code is shifted around in the future. + let Some(chain_config) = state.chain_config.resolve() else { + return Err(ExecutionError::UnresolvableChainConfig); + }; + + let recipient = chain_config.bid_recipient; + // Charge the bid amount + state + .charge_fee(FeeInfo::new(self.account(), self.amount()), recipient) + .map_err(ExecutionError::from)?; + + // TODO are gas and bid funded to same recipient? Possibly + // gas would be funded to recipient sequencing + // fee recipient? + // Charge the the gas amount + state + .charge_fee(FeeInfo::new(self.account(), self.gas_price()), recipient) + .map_err(ExecutionError::from)?; + + Ok(()) + } + /// Cryptographic signature verification + fn verify(&self) -> Result<(), ExecutionError> { + self.body + .account + .validate_builder_signature(&self.signature, self.body.commit().as_ref()) + .then_some(()) + .ok_or(ExecutionError::InvalidSignature) + } + /// Return the body of the transaction + pub fn body(self) -> BidTxBody { + self.body + } + /// Update `url` field on a previously instantiated `BidTxBody`. + pub fn with_url(self, url: Url) -> Self { + let body = self.body.with_url(url); + Self { body, ..self } + } + /// get gas price + pub fn gas_price(&self) -> FeeAmount { + self.body.gas_price + } + /// get bid amount + pub fn amount(&self) -> FeeAmount { + self.body.bid_amount + } + /// get bid amount + pub fn account(&self) -> FeeAccount { + self.body.account + } +} + +impl HasUrl for BidTx { + /// Get the `url` field from the body. + fn url(&self) -> Url { + self.body.url() + } +} + +impl HasUrl for BidTxBody { + /// Get the cloned `url` field. + fn url(&self) -> Url { + self.url.clone() + } +} + +pub fn mock_full_network_txs(key: Option) -> Vec { + // if no key is supplied, use `test_key_pair`. Since default `BidTxBody` is + // signed with `test_key_pair`, it will verify successfully + let key = key.unwrap_or_else(FeeAccount::test_key_pair); + vec![FullNetworkTx::Bid(BidTx::mock(key))] +} + +mod test { + use super::*; + + impl BidTx { + pub fn mock(key: EthKeyPair) -> Self { + BidTxBody::default().signed(&key).unwrap() + } + } + + #[test] + fn test_mock_bid_tx_sign_and_verify() { + let key = FeeAccount::test_key_pair(); + let bidtx = BidTx::mock(key); + bidtx.verify().unwrap(); + } + + #[test] + fn test_mock_bid_tx_charge() { + let mut state = ValidatedState::default(); + let key = FeeAccount::test_key_pair(); + let bidtx = BidTx::mock(key); + bidtx.charge(&mut state).unwrap(); + } + + #[test] + fn test_bid_tx_construct() { + let key_pair = EthKeyPair::random(); + BidTxBody::new( + key_pair.fee_account(), + FeeAmount::from(1), + ViewNumber::genesis(), + vec![NamespaceId::from(999)], + ) + .signed(&key_pair) + .unwrap() + .verify() + .unwrap(); + } +} diff --git a/sequencer/src/chain_config.rs b/sequencer/src/chain_config.rs index 5140d6ca5..d70b4be23 100644 --- a/sequencer/src/chain_config.rs +++ b/sequencer/src/chain_config.rs @@ -110,6 +110,9 @@ pub struct ChainConfig { /// 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. pub fee_recipient: FeeAccount, + + /// Account that receives sequencing bids. + pub bid_recipient: FeeAccount, } impl Default for ChainConfig { @@ -120,6 +123,7 @@ impl Default for ChainConfig { base_fee: 0.into(), fee_contract: None, fee_recipient: Default::default(), + bid_recipient: Default::default(), } } } diff --git a/sequencer/src/eth_signature_key.rs b/sequencer/src/eth_signature_key.rs index da822372b..6ca1de0a2 100644 --- a/sequencer/src/eth_signature_key.rs +++ b/sequencer/src/eth_signature_key.rs @@ -47,6 +47,9 @@ impl EthKeyPair { let signing_key: &SigningKey = derived_priv_key.as_ref(); Ok(signing_key.clone().into()) } + pub fn random() -> EthKeyPair { + SigningKey::random(&mut rand::thread_rng()).into() + } pub fn fee_account(&self) -> FeeAccount { self.fee_account diff --git a/sequencer/src/genesis.rs b/sequencer/src/genesis.rs index 9c013090d..1ea47c6cb 100644 --- a/sequencer/src/genesis.rs +++ b/sequencer/src/genesis.rs @@ -276,6 +276,7 @@ mod test { max_block_size: 30000.into(), base_fee: 1.into(), fee_recipient: FeeAccount::default(), + bid_recipient: FeeAccount::default(), fee_contract: Some(Address::default()) } ); @@ -340,6 +341,7 @@ mod test { max_block_size: 30000.into(), base_fee: 1.into(), fee_recipient: FeeAccount::default(), + bid_recipient: FeeAccount::default(), fee_contract: None, } ); diff --git a/sequencer/src/header.rs b/sequencer/src/header.rs index 8b89f215c..15025aac6 100644 --- a/sequencer/src/header.rs +++ b/sequencer/src/header.rs @@ -1,4 +1,7 @@ +use std::collections::HashSet; + use crate::{ + auction::{mock_full_network_txs, FullNetworkTx}, block::NsTable, chain_config::ResolvableChainConfig, eth_signature_key::BuilderSignature, @@ -95,6 +98,10 @@ pub struct Header { /// that `fee_info` is correct without relying on the signature. Thus, this signature is not /// included in the header commitment. pub builder_signature: Option, + // pub full_network_txs: Vec, + // /// refund flag set at the beginning of new slots + // /// In extreme cases, more than one slot may need to be refunded, + // /// hence this data structure } impl Committable for Header { @@ -133,6 +140,11 @@ impl Committable for Header { } impl Header { + // TODO move to BlockHeader + pub fn get_full_network_txs(&self) -> Vec { + // TODO unmock + mock_full_network_txs(None) + } #[allow(clippy::too_many_arguments)] fn from_info( payload_commitment: VidCommitment, diff --git a/sequencer/src/lib.rs b/sequencer/src/lib.rs index b617fbaef..40cca29fb 100644 --- a/sequencer/src/lib.rs +++ b/sequencer/src/lib.rs @@ -1,4 +1,5 @@ pub mod api; +pub mod auction; pub mod block; pub mod catchup; mod chain_config; diff --git a/sequencer/src/reference_tests.rs b/sequencer/src/reference_tests.rs old mode 100644 new mode 100755 index 0bc43bb0b..4a7398a95 --- a/sequencer/src/reference_tests.rs +++ b/sequencer/src/reference_tests.rs @@ -90,6 +90,7 @@ fn reference_chain_config() -> ChainConfig { base_fee: 0.into(), fee_contract: Some(Default::default()), fee_recipient: Default::default(), + bid_recipient: Default::default(), } } diff --git a/sequencer/src/state.rs b/sequencer/src/state.rs index 257e40808..b49051779 100644 --- a/sequencer/src/state.rs +++ b/sequencer/src/state.rs @@ -1,9 +1,9 @@ use crate::{ api::data_source::CatchupDataSource, block::{NsTableValidationError, PayloadByteLen}, + auction::{BidTx, ExecutionError, FullNetworkTx}, catchup::SqlStateCatchup, - chain_config::BlockSize, - chain_config::ResolvableChainConfig, + chain_config::{BlockSize, ResolvableChainConfig}, eth_signature_key::EthKeyPair, genesis::UpgradeType, persistence::ChainConfigPersistence, @@ -774,6 +774,10 @@ impl ValidatedState { let mut validated_state = apply_proposal(&validated_state, &mut delta, parent_leaf, l1_deposits); + // TODO I guess we will need deltas for this? + apply_full_transactions(&mut validated_state, proposed_header.get_full_network_txs()) + .map_err(|e| anyhow::anyhow!("Error: {e:?}"))?; + charge_fee( &mut validated_state, &mut delta, @@ -947,6 +951,7 @@ impl HotShotState for ValidatedState { if parent_leaf.view_number().u64() % 10 == 0 { tracing::info!("validated and applied new header"); } + Ok((validated_state, delta)) } /// Construct the state with the given block header. @@ -980,6 +985,16 @@ impl HotShotState for ValidatedState { } } +fn apply_full_transactions( + validated_state: &mut ValidatedState, + full_network_txs: Vec, +) -> Result<(), (ExecutionError, FullNetworkTx)> { + dbg!(&full_network_txs); + full_network_txs + .iter() + .try_for_each(|tx| tx.execute(validated_state)) +} + // Required for TestableState #[cfg(any(test, feature = "testing"))] impl std::fmt::Display for ValidatedState { @@ -1102,6 +1117,16 @@ impl From for FeeInfo { } } +impl From for FeeInfo { + fn from(bid: BidTx) -> Self { + let bid = bid.body(); + Self { + amount: bid.amount(), + account: bid.account(), + } + } +} + impl Committable for FeeInfo { fn commit(&self) -> Commitment { RawCommitmentBuilder::new(&Self::tag()) @@ -1474,6 +1499,8 @@ impl FeeAccountProof { #[cfg(test)] mod test { + use crate::auction::mock_full_network_txs; + use super::*; use async_compatibility_layer::logging::{setup_backtrace, setup_logging}; use hotshot_types::vid::vid_scheme; @@ -1639,6 +1666,21 @@ mod test { ); } + #[test] + fn test_apply_full_tx() { + let mut state = ValidatedState::default(); + let txs = mock_full_network_txs(None); + // Default key can be verified b/c it is the same that signs the mock tx + apply_full_transactions(&mut state, txs).unwrap(); + + // Tx will be invalid if it is signed by a different key than + // set in `account` field. + let key = FeeAccount::generated_from_seed_indexed([1; 32], 0).1; + let invalid = mock_full_network_txs(Some(key)); + let (err, _) = apply_full_transactions(&mut state, invalid).unwrap_err(); + assert_eq!(ExecutionError::InvalidSignature, err); + } + #[test] fn test_fee_amount_serde_json_as_decimal() { let amt = FeeAmount::from(123);