diff --git a/Cargo.toml b/Cargo.toml index c6fc5d9..bcbf0c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,10 @@ repository = "https://github.com/init4tech/zenith" license = "AGPL-3.0" [dependencies] +alloy = { version = "=0.7.3", features = ["full", "json-rpc", "signer-aws", "rpc-types-mev"] } alloy-primitives = { version = "0.8.11", features = ["serde", "tiny-keccak"] } alloy-sol-types = { version = "0.8.11", features = ["json"] } - alloy-rlp = { version = "0.3.4" } - -alloy = { version = "=0.7.3", features = ["full", "json-rpc", "signer-aws"] } alloy-contract = { version = "=0.7.3", features = ["pubsub"] } serde = { version = "1.0.197", features = ["derive"] } @@ -24,4 +22,3 @@ serde = { version = "1.0.197", features = ["derive"] } [dev-dependencies] serde_json = "1.0.94" tokio = { version = "1.37.0", features = ["macros"] } - diff --git a/src/bundle.rs b/src/bundle.rs new file mode 100644 index 0000000..4ee9800 --- /dev/null +++ b/src/bundle.rs @@ -0,0 +1,278 @@ +use alloy::{ + eips::{eip2718::Encodable2718, BlockNumberOrTag}, + rpc::types::mev::{EthCallBundle, EthCallBundleResponse, EthSendBundle}, +}; +use alloy_primitives::{keccak256, Address, Bytes, B256, U256}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::SignedOrder; + +/// Wraps a flashbots style EthSendBundle with host fills to make a Zenith compatible bundle +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZenithEthBundle { + /// The bundle of transactions to simulate. Same structure as a Flashbots [EthSendBundle] bundle. + /// see + #[serde(flatten)] + pub bundle: EthSendBundle, + /// Host fills to be applied with the bundle, represented as a signed permit2 order. + pub host_fills: Option, +} + +impl ZenithEthBundle { + /// Returns the transactions in this bundle. + pub fn txs(&self) -> &[Bytes] { + &self.bundle.txs + } + + /// Returns the block number for this bundle. + pub const fn block_number(&self) -> u64 { + self.bundle.block_number + } + + /// Returns the minimum timestamp for this bundle. + pub const fn min_timestamp(&self) -> Option { + self.bundle.min_timestamp + } + + /// Returns the maximum timestamp for this bundle. + pub const fn max_timestamp(&self) -> Option { + self.bundle.max_timestamp + } + + /// Returns the reverting tx hashes for this bundle. + pub fn reverting_tx_hashes(&self) -> &[B256] { + self.bundle.reverting_tx_hashes.as_slice() + } + + /// Returns the replacement uuid for this bundle. + pub fn replacement_uuid(&self) -> Option<&str> { + self.bundle.replacement_uuid.as_deref() + } +} + +/// Response for `zenith_sendBundle` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZenithEthBundleResponse { + /// The bundle hash of the sent bundle. + /// + /// This is calculated as keccak256(tx_hashes) where tx_hashes are the concatenated transaction hashes. + pub bundle_hash: B256, +} + +/// Bundle of transactions for `zenith_callBundle` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZenithCallBundle { + /// The bundle of transactions to simulate. Same structure as a Flashbots [EthCallBundle] bundle. + /// see + #[serde(flatten)] + pub bundle: EthCallBundle, + /// Host fills to be applied to the bundle for simulation. The mapping corresponds + /// to asset => user => amount. + pub host_fills: BTreeMap>, +} + +impl ZenithCallBundle { + /// Returns the host fills for this bundle. + pub const fn host_fills(&self) -> &BTreeMap> { + &self.host_fills + } + + /// Returns the transactions in this bundle. + pub fn txs(&self) -> &[Bytes] { + &self.bundle.txs + } + + /// Returns the block number for this bundle. + pub const fn block_number(&self) -> u64 { + self.bundle.block_number + } + + /// Returns the state block number for this bundle. + pub const fn state_block_number(&self) -> BlockNumberOrTag { + self.bundle.state_block_number + } + + /// Returns the timestamp for this bundle. + pub const fn timestamp(&self) -> Option { + self.bundle.timestamp + } + + /// Returns the gas limit for this bundle. + pub const fn gas_limit(&self) -> Option { + self.bundle.gas_limit + } + + /// Returns the difficulty for this bundle. + pub const fn difficulty(&self) -> Option { + self.bundle.difficulty + } + + /// Returns the base fee for this bundle. + pub const fn base_fee(&self) -> Option { + self.bundle.base_fee + } + + /// Creates a new bundle from the given [`Encodable2718`] transactions. + pub fn from_2718_and_host_fills( + txs: I, + host_fills: BTreeMap>, + ) -> Self + where + I: IntoIterator, + T: Encodable2718, + { + Self::from_raw_txs_and_host_fills(txs.into_iter().map(|tx| tx.encoded_2718()), host_fills) + } + + /// Creates a new bundle with the given transactions and host fills. + pub fn from_raw_txs_and_host_fills( + txs: I, + host_fills: BTreeMap>, + ) -> Self + where + I: IntoIterator, + T: Into, + { + Self { + bundle: EthCallBundle { + txs: txs.into_iter().map(Into::into).collect(), + ..Default::default() + }, + host_fills, + } + } + + /// Adds an [`Encodable2718`] transaction to the bundle. + pub fn append_2718_tx(self, tx: impl Encodable2718) -> Self { + self.append_raw_tx(tx.encoded_2718()) + } + + /// Adds an EIP-2718 envelope to the bundle. + pub fn append_raw_tx(mut self, tx: impl Into) -> Self { + self.bundle.txs.push(tx.into()); + self + } + + /// Adds multiple [`Encodable2718`] transactions to the bundle. + pub fn extend_2718_txs(self, tx: I) -> Self + where + I: IntoIterator, + T: Encodable2718, + { + self.extend_raw_txs(tx.into_iter().map(|tx| tx.encoded_2718())) + } + + /// Adds multiple calls to the block. + pub fn extend_raw_txs(mut self, txs: I) -> Self + where + I: IntoIterator, + T: Into, + { + self.bundle.txs.extend(txs.into_iter().map(Into::into)); + self + } + + /// Sets the block number for the bundle. + pub const fn with_block_number(mut self, block_number: u64) -> Self { + self.bundle.block_number = block_number; + self + } + + /// Sets the state block number for the bundle. + pub fn with_state_block_number( + mut self, + state_block_number: impl Into, + ) -> Self { + self.bundle.state_block_number = state_block_number.into(); + self + } + + /// Sets the timestamp for the bundle. + pub const fn with_timestamp(mut self, timestamp: u64) -> Self { + self.bundle.timestamp = Some(timestamp); + self + } + + /// Sets the gas limit for the bundle. + pub const fn with_gas_limit(mut self, gas_limit: u64) -> Self { + self.bundle.gas_limit = Some(gas_limit); + self + } + + /// Sets the difficulty for the bundle. + pub const fn with_difficulty(mut self, difficulty: U256) -> Self { + self.bundle.difficulty = Some(difficulty); + self + } + + /// Sets the base fee for the bundle. + pub const fn with_base_fee(mut self, base_fee: u128) -> Self { + self.bundle.base_fee = Some(base_fee); + self + } + + /// Make a bundle hash from the given deserialized transaction array and host fills from this bundle. + /// The hash is calculated as keccak256(tx_preimage + host_preimage). + /// The tx_preimage is calculated as `keccak(tx_hash1 + tx_hash2 + ... + tx_hashn)`. + /// The host_preimage is calculated as + /// `keccak(NUM_OF_ASSETS_LE + asset1 + NUM_OF_FILLS_LE + asset1_user1 + user1_amount2 + ... + asset1_usern + asset1_amountn + ...)`. + /// For the number of users/fills and amounts in the host_preimage, the amounts are serialized as little-endian U256 slice. + pub fn bundle_hash(&self) -> B256 { + let mut hasher = alloy_primitives::Keccak256::new(); + + // Concatenate the transaction hashes, to then hash them. This is the tx_preimage. + for tx in self.bundle.txs.iter() { + // Calculate the tx hash (keccak256(encoded_signed_tx)) and append it to the tx_bytes. + hasher.update(keccak256(tx).as_slice()); + } + let tx_preimage = hasher.finalize(); + + // Now, let's build the host_preimage. We do it in steps: + // 1. Prefix the number of assets, encoded as a little-endian U256 slice. + // 2. For each asset: + // 3. Concatenate the asset address. + // 4. Prefix the number of fills. + // 5. For each fill, concatenate the user and amount, the latter encoded as a little-endian U256 slice. + let mut hasher = alloy_primitives::Keccak256::new(); + + // Prefix the list of users with the number of assets. + hasher.update(U256::from(self.host_fills.len()).as_le_slice()); + + for (asset, fills) in self.host_fills.iter() { + // Concatenate the asset address. + hasher.update(asset.as_slice()); + + // Prefix the list of fills with the number of fills + hasher.update(U256::from(fills.len()).as_le_slice()); + + for (user, amount) in fills.iter() { + // Concatenate the user address and amount for each fill. + hasher.update(user.as_slice()); + hasher.update(amount.as_le_slice()); + } + } + + // Hash the host pre-image. + let host_preimage = hasher.finalize(); + + let mut pre_image = alloy_primitives::Keccak256::new(); + pre_image.update(tx_preimage.as_slice()); + pre_image.update(host_preimage.as_slice()); + + // Hash both tx and host hashes to get the final bundle hash. + pre_image.finalize() + } +} + +/// Response for `zenith_callBundle` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ZenithCallBundleResponse { + /// The flattened "vanilla" response which comes from `eth_callBundle` + #[serde(flatten)] + pub response: EthCallBundleResponse, +} diff --git a/src/lib.rs b/src/lib.rs index 2f8f8b4..bfb1cc6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,11 @@ pub use block::{decode_txns, encode_txns, Alloy2718Coder, Coder, ZenithBlock, Ze mod orders; pub use orders::{AggregateOrders, SignedOrder}; +mod bundle; +pub use bundle::{ + ZenithCallBundle, ZenithCallBundleResponse, ZenithEthBundle, ZenithEthBundleResponse, +}; + mod req; pub use req::SignRequest;