diff --git a/Cargo.lock b/Cargo.lock index e0c8ea2c..13903085 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9042,6 +9042,7 @@ dependencies = [ "logos-blockchain-key-management-system-service", "logos-blockchain-zone-sdk", "mempool", + "num-bigint 0.4.6", "rand 0.8.6", "risc0-zkvm", "serde", diff --git a/Cargo.toml b/Cargo.toml index 290c3540..0270db7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,6 +133,7 @@ chrono = "0.4.41" borsh = "1.5.7" base58 = "0.2.0" itertools = "0.14.0" +num-bigint = "0.4.6" url = { version = "2.5.4", features = ["serde"] } tokio-retry = "0.3.0" schemars = "1.2" diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 81f62f2b..cc13e625 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -16,7 +16,7 @@ use lee::{ }; use lee_core::{InputAccountIdentity, account::AccountWithMetadata}; use log::info; -use logos_blockchain_core::mantle::{Value, ledger::Inputs, ops::channel::deposit::DepositOp}; +use logos_blockchain_core::mantle::{ledger::Inputs, ops::channel::deposit::DepositOp}; use logos_blockchain_http_api_common::bodies::{ channel::ChannelDepositRequestBody, wallet::{ @@ -197,7 +197,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { async fn submit_bedrock_deposit( bedrock_addr: std::net::SocketAddr, recipient_id: AccountId, - amount: u128, + amount: u64, ) -> anyhow::Result<()> { #[derive(BorshSerialize)] struct DepositMetadata { @@ -212,9 +212,6 @@ async fn submit_bedrock_deposit( let funding_key = "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26"; - let amount: Value = amount - .try_into() - .context("Deposit amount does not fit Bedrock Value type")?; let channel_id = integration_tests::config::bedrock_channel_id(); let client = reqwest::Client::new(); diff --git a/lez/common/src/transaction.rs b/lez/common/src/transaction.rs index 42a7b761..625c7920 100644 --- a/lez/common/src/transaction.rs +++ b/lez/common/src/transaction.rs @@ -90,14 +90,16 @@ impl LeeTransaction { } }?; - let system_accounts = lee::CLOCK_PROGRAM_ACCOUNT_IDS.iter().copied().chain([ - lee::system_faucet_account_id(), - lee::system_bridge_account_id(), - ]); + let system_accounts = lee::CLOCK_PROGRAM_ACCOUNT_IDS + .iter() + .copied() + .chain(std::iter::once(lee::system_faucet_account_id())); for account_id in system_accounts { validate_doesnt_modify_account(state, &diff, account_id)?; } + self.validate_bridge_account_modification(state, &diff)?; + Ok(diff) } @@ -115,6 +117,50 @@ impl LeeTransaction { state.apply_state_diff(diff); Ok(self) } + + fn validate_bridge_account_modification( + &self, + state: &V03State, + diff: &ValidatedStateDiff, + ) -> Result<(), lee::error::LeeError> { + let bridge_account_id = lee::system_bridge_account_id(); + let pre = state.get_account_by_id(bridge_account_id); + let Some(post) = diff.public_diff().get(&bridge_account_id).cloned() else { + return Ok(()); + }; + + let Self::Public(_) = self else { + return Err(lee::error::LeeError::InvalidInput(format!( + "Non-public transaction cannot modify system bridge account {bridge_account_id}" + ))); + }; + + let lee::Account { + balance: pre_balance, + program_owner: pre_program_owner, + data: pre_data, + nonce: pre_nonce, + } = pre; + let lee::Account { + balance: post_balance, + program_owner: post_program_owner, + data: post_data, + nonce: post_nonce, + } = post; + + let only_balance_increased = post_balance >= pre_balance + && post_program_owner == pre_program_owner + && post_data == pre_data + && post_nonce == pre_nonce; + + if only_balance_increased { + Ok(()) + } else { + Err(lee::error::LeeError::InvalidInput(format!( + "Transaction modifies restricted system bridge account {bridge_account_id}" + ))) + } + } } impl From for LeeTransaction { diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index f7296f42..d5bd5c3a 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -19,6 +19,8 @@ faucet_core.workspace = true bridge_core.workspace = true vault_core.workspace = true +logos-blockchain-key-management-system-service.workspace = true +logos-blockchain-core.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true @@ -27,13 +29,12 @@ tempfile.workspace = true chrono.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } -logos-blockchain-key-management-system-service.workspace = true -logos-blockchain-core.workspace = true rand.workspace = true borsh.workspace = true bytesize.workspace = true hex.workspace = true url.workspace = true +num-bigint.workspace = true risc0-zkvm.workspace = true [features] diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index f07a47c6..6c7ab1cd 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -3,8 +3,8 @@ use std::{pin::Pin, sync::Arc, time::Duration}; use anyhow::{Context as _, Result}; use common::block::Block; use log::warn; -pub use logos_blockchain_core::mantle::ops::channel::MsgId; -pub use logos_blockchain_key_management_system_service::keys::Ed25519Key; +use logos_blockchain_core::mantle::{Note, ledger::Outputs}; +pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey}; pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use logos_blockchain_zone_sdk::{ CommonHttpClient, @@ -12,9 +12,10 @@ use logos_blockchain_zone_sdk::{ sequencer::{Event, SequencerConfig as ZoneSdkSequencerConfig, SequencerHandle, ZoneSequencer}, state::{DepositInfo, FinalizedOp, InscriptionInfo}, }; +use num_bigint::BigUint; use tokio::task::JoinHandle; -use crate::config::BedrockConfig; +use crate::{BridgeWithdrawData, config::BedrockConfig}; /// Sink for `Event::Published` checkpoints emitted by the drive task. /// Caller is responsible for persistence (e.g. writing to rocksdb). @@ -43,7 +44,11 @@ pub trait BlockPublisherTrait: Clone { /// Fire-and-forget publish. Zone-sdk drives the actual submission and /// retries internally; this just hands the payload off. - async fn publish_block(&self, block: &Block) -> Result<()>; + async fn publish_block( + &self, + block: &Block, + bridge_withdrawals: Vec, + ) -> Result<()>; } /// Real block publisher backed by zone-sdk's `ZoneSequencer`. @@ -127,17 +132,42 @@ impl BlockPublisherTrait for ZoneSdkPublisher { }) } - async fn publish_block(&self, block: &Block) -> Result<()> { + async fn publish_block( + &self, + block: &Block, + bridge_withdrawals: Vec, + ) -> Result<()> { let data = borsh::to_vec(block).context("Failed to serialize block")?; let data_bounded = data .try_into() .context("Block data exceeds maximum allowed size")?; - self.handle - .publish_message(data_bounded) - .await - .context("Failed to publish block")?; + if bridge_withdrawals.is_empty() { + self.handle + .publish_message(data_bounded) + .await + .context("Failed to publish block")?; + return Ok(()); + } + let withdraws = bridge_withdrawals + .into_iter() + .map(|withdrawal| { + let recipient_pk = + logos_blockchain_key_management_system_service::keys::ZkPublicKey::from( + BigUint::from_bytes_le(&withdrawal.bedrock_account_pk), + ); + + logos_blockchain_zone_sdk::sequencer::WithdrawArg { + outputs: Outputs::new(Note::new(withdrawal.amount, recipient_pk)), + } + }) + .collect(); + + self.handle + .publish_atomic_withdraw(data_bounded, withdraws) + .await + .context("Failed to publish block with withdrawals")?; Ok(()) } } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 0f836c00..b7321ba5 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -248,7 +248,7 @@ impl SequencerCore { // restarts. if is_fresh_start { block_publisher - .publish_block(&genesis_block) + .publish_block(&genesis_block, vec![]) .await .expect("Failed to publish genesis block"); } @@ -267,20 +267,20 @@ impl SequencerCore { /// Produces a new block from mempool transactions and publishes it via zone-sdk. pub async fn produce_new_block(&mut self) -> Result { - let block_with_meta = self - .build_block_from_mempool() - .context("Failed to build block from mempool transactions")?; let BlockWithMeta { block, deposit_event_ids, - } = block_with_meta; + bridge_withdrawals, + } = self + .build_block_from_mempool() + .context("Failed to build block from mempool transactions")?; // TODO: Remove msg_id from store.update — it is no longer needed now that // zone-sdk manages L1 settlement state via its own checkpoint. let placeholder_msg_id = [0_u8; 32]; self.block_publisher - .publish_block(&block) + .publish_block(&block, bridge_withdrawals) .await .context("Failed to publish block to Bedrock")?; @@ -308,6 +308,7 @@ impl SequencerCore { let mut valid_transactions = Vec::new(); let mut deposit_event_ids = Vec::new(); + let mut bridge_withdrawals = Vec::new(); let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64()) .expect("`max_block_size` should fit into usize"); @@ -372,6 +373,10 @@ impl SequencerCore { } }; + if let Some(withdraw_data) = extract_bridge_withdraw_data(&tx) { + bridge_withdrawals.push(withdraw_data); + } + self.state.apply_state_diff(validated_diff); } TransactionOrigin::Sequencer => { @@ -379,17 +384,8 @@ impl SequencerCore { panic!("Sequencer may only generate Public transactions, found {tx:#?}"); }; - if public_tx.message.program_id == Program::bridge().id() { - let instruction: bridge_core::Instruction = - risc0_zkvm::serde::from_slice(&public_tx.message.instruction_data) - .context("Failed to deserialize bridge instruction")?; - match instruction { - bridge_core::Instruction::Deposit { - l1_deposit_op_id, .. - } => { - deposit_event_ids.push(HashType(l1_deposit_op_id)); - } - } + if let Some(deposit_op_id) = extract_bridge_deposit_id(&tx) { + deposit_event_ids.push(deposit_op_id); } self.state @@ -403,6 +399,7 @@ impl SequencerCore { } valid_transactions.push(tx); + info!("Validated transaction with hash {tx_hash}, including it in block"); if valid_transactions.len() >= self.sequencer_config.max_num_tx_in_block { break; @@ -440,6 +437,7 @@ impl SequencerCore { Ok(BlockWithMeta { block, deposit_event_ids, + bridge_withdrawals, }) } @@ -499,6 +497,13 @@ impl SequencerCore { struct BlockWithMeta { block: Block, deposit_event_ids: Vec, + bridge_withdrawals: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BridgeWithdrawData { + pub amount: u64, + pub bedrock_account_pk: [u8; 32], } /// Checks the database for any pending deposit events that have not yet been marked as submitted in @@ -653,7 +658,7 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu l1_deposit_op_id: event.deposit_op_id.0, vault_program_id, recipient_id: metadata.recipient_id, - amount: u128::from(event.amount), + amount: event.amount, }, ) .context("Failed to build bridge deposit message")?; @@ -665,6 +670,58 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu ))) } +#[must_use] +fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option { + let LeeTransaction::Public(tx) = tx else { + return None; + }; + + let message = tx.message(); + if message.program_id != lee::program::Program::bridge().id() { + return None; + } + + let instruction = + risc0_zkvm::serde::from_slice::(&message.instruction_data) + .ok()?; + + match instruction { + bridge_core::Instruction::Deposit { + l1_deposit_op_id, .. + } => Some(HashType(l1_deposit_op_id)), + bridge_core::Instruction::Withdraw { .. } => None, + } +} + +#[must_use] +fn extract_bridge_withdraw_data(tx: &LeeTransaction) -> Option { + let LeeTransaction::Public(tx) = tx else { + return None; + }; + + let message = tx.message(); + if message.program_id != lee::program::Program::bridge().id() { + return None; + } + + let instruction = + risc0_zkvm::serde::from_slice::(&message.instruction_data) + .ok()?; + + match instruction { + bridge_core::Instruction::Withdraw { + amount, + bedrock_account_pk, + } => Some(BridgeWithdrawData { + amount, + bedrock_account_pk, + }), + bridge_core::Instruction::Deposit { .. } => unreachable!( + "Deposit instructions from users should never pass validation, and thus should never be seen here" + ), + } +} + /// Load signing key from file or generate a new one if it doesn't exist. fn load_or_create_signing_key(path: &Path) -> Result { if path.exists() { @@ -801,7 +858,7 @@ mod tests { l1_deposit_op_id, amount, .. - } if l1_deposit_op_id == deposit_op_id && amount == u128::from(expected_amount) + } if l1_deposit_op_id == deposit_op_id && amount == expected_amount ) } diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index e041269a..0b0024ac 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -5,6 +5,7 @@ use common::block::Block; use logos_blockchain_key_management_system_service::keys::Ed25519Key; use crate::{ + BridgeWithdrawData, block_publisher::{ BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, SequencerCheckpoint, @@ -30,7 +31,11 @@ impl BlockPublisherTrait for MockBlockPublisher { Ok(Self) } - async fn publish_block(&self, _block: &Block) -> Result<()> { + async fn publish_block( + &self, + _block: &Block, + _bridge_withdrawals: Vec, + ) -> Result<()> { Ok(()) } } diff --git a/program_methods/guest/src/bin/bridge.rs b/program_methods/guest/src/bin/bridge.rs index eb082c7c..19d6509c 100644 --- a/program_methods/guest/src/bin/bridge.rs +++ b/program_methods/guest/src/bin/bridge.rs @@ -63,12 +63,40 @@ fn main() { vec![bridge_for_vault, recipient_vault], &vault_core::Instruction::Transfer { recipient_id, - amount, + amount: u128::from(amount), }, ) .with_pda_seeds(vec![bridge_core::compute_bridge_seed()]), ] } + Instruction::Withdraw { + amount, + bedrock_account_pk: _, + } => { + let [sender, bridge] = pre_states + .try_into() + .expect("Withdraw requires exactly 2 accounts"); + + assert_eq!( + bridge.account_id, + bridge_core::compute_bridge_account_id(self_program_id), + "Second account must be bridge PDA" + ); + + let auth_transfer_program_id = bridge.account.program_owner; + assert_eq!( + sender.account.program_owner, auth_transfer_program_id, + "Sender account must be owned by the authenticated transfer program" + ); + + vec![ChainedCall::new( + auth_transfer_program_id, + vec![sender, bridge], + &authenticated_transfer_core::Instruction::Transfer { + amount: u128::from(amount), + }, + )] + } }; ProgramOutput::new( diff --git a/programs/bridge/core/src/lib.rs b/programs/bridge/core/src/lib.rs index 1e1bff4f..c9666f27 100644 --- a/programs/bridge/core/src/lib.rs +++ b/programs/bridge/core/src/lib.rs @@ -17,7 +17,20 @@ pub enum Instruction { l1_deposit_op_id: [u8; 32], vault_program_id: ProgramId, recipient_id: AccountId, - amount: u128, + amount: u64, + }, + + /// Transfers native tokens from a user account to the bridge PDA account. + /// + /// Required accounts (2): + /// - Sender account + /// - Bridge PDA account + /// + /// `bedrock_account_pk` is consumed by the Sequencer and is not used by the Bridge program + /// logic. + Withdraw { + amount: u64, + bedrock_account_pk: [u8; 32], }, }