From b8471c0f0ef39aec3d55fde4225a376e5c743b58 Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Tue, 2 Jun 2026 00:42:41 +0300 Subject: [PATCH] feat(sequencer): implement bridge withdraw flow --- Cargo.lock | 2 + Cargo.toml | 1 + common/src/transaction.rs | 47 ++++++++++++++-- integration_tests/tests/bridge.rs | 7 +-- program_methods/guest/src/bin/bridge.rs | 30 +++++++++- programs/bridge/core/src/lib.rs | 15 ++++- sequencer/core/Cargo.toml | 6 +- sequencer/core/src/block_publisher.rs | 48 +++++++++++++--- sequencer/core/src/lib.rs | 74 +++++++++++++++++++++++-- sequencer/core/src/mock.rs | 7 ++- 10 files changed, 208 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0bad6271..37cbd0a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8868,7 +8868,9 @@ dependencies = [ "mempool", "nssa", "nssa_core", + "num-bigint 0.4.6", "rand 0.8.5", + "risc0-zkvm", "serde", "serde_json", "storage", diff --git a/Cargo.toml b/Cargo.toml index ca9880ab..c9917c7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,6 +131,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/common/src/transaction.rs b/common/src/transaction.rs index 0015e9a9..726961b0 100644 --- a/common/src/transaction.rs +++ b/common/src/transaction.rs @@ -90,14 +90,16 @@ impl NSSATransaction { } }?; - let system_accounts = nssa::CLOCK_PROGRAM_ACCOUNT_IDS.iter().copied().chain([ - nssa::system_faucet_account_id(), - nssa::system_bridge_account_id(), - ]); + let system_accounts = nssa::CLOCK_PROGRAM_ACCOUNT_IDS + .iter() + .copied() + .chain(std::iter::once(nssa::system_faucet_account_id())); for account_id in system_accounts { validate_doesnt_modify_account(state, &diff, account_id)?; } + validate_bridge_account_modification(state, &diff)?; + Ok(diff) } @@ -188,3 +190,40 @@ fn validate_doesnt_modify_account( Ok(()) } } + +fn validate_bridge_account_modification( + state: &V03State, + diff: &ValidatedStateDiff, +) -> Result<(), nssa::error::NssaError> { + let bridge_account_id = nssa::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 nssa::Account { + balance: pre_balance, + program_owner: pre_program_owner, + data: pre_data, + nonce: pre_nonce, + } = pre; + let nssa::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(nssa::error::NssaError::InvalidInput(format!( + "Transaction modifies restricted system account {bridge_account_id}" + ))) + } +} diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 7cb63b22..cfd361d3 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -11,7 +11,7 @@ use borsh::BorshSerialize; use common::transaction::NSSATransaction; use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext}; 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::{ @@ -195,7 +195,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 { @@ -208,9 +208,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/program_methods/guest/src/bin/bridge.rs b/program_methods/guest/src/bin/bridge.rs index 0833d5aa..3f9caa32 100644 --- a/program_methods/guest/src/bin/bridge.rs +++ b/program_methods/guest/src/bin/bridge.rs @@ -62,12 +62,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 1da75f97..edca3b26 100644 --- a/programs/bridge/core/src/lib.rs +++ b/programs/bridge/core/src/lib.rs @@ -14,7 +14,20 @@ pub enum Instruction { Deposit { 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], }, } diff --git a/sequencer/core/Cargo.toml b/sequencer/core/Cargo.toml index 985fc969..1c98040a 100644 --- a/sequencer/core/Cargo.toml +++ b/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,13 @@ 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] default = [] diff --git a/sequencer/core/src/block_publisher.rs b/sequencer/core/src/block_publisher.rs index 62cd7259..b0aa7b0e 100644 --- a/sequencer/core/src/block_publisher.rs +++ b/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`. @@ -124,17 +129,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(vec![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/sequencer/core/src/lib.rs b/sequencer/core/src/lib.rs index 13b455df..01306ae8 100644 --- a/sequencer/core/src/lib.rs +++ b/sequencer/core/src/lib.rs @@ -199,7 +199,9 @@ impl SequencerCore { // genesis block so the indexer can find the channel start. After the // first publish, zone-sdk's checkpoint persistence covers further // restarts. - if is_fresh_start && let Err(err) = block_publisher.publish_block(&genesis_block).await { + if is_fresh_start + && let Err(err) = block_publisher.publish_block(&genesis_block, vec![]).await + { error!("Failed to publish genesis block: {err:#}"); } @@ -217,7 +219,10 @@ 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 = self + let BlockWithMeta { + block, + bridge_withdrawals, + } = self .build_block_from_mempool() .context("Failed to build block from mempool transactions")?; @@ -225,7 +230,11 @@ impl SequencerCore { // zone-sdk manages L1 settlement state via its own checkpoint. let placeholder_msg_id = [0_u8; 32]; - if let Err(err) = self.block_publisher.publish_block(&block).await { + if let Err(err) = self + .block_publisher + .publish_block(&block, bridge_withdrawals) + .await + { error!("Failed to publish block to Bedrock with error: {err:#}"); } self.store.update(&block, placeholder_msg_id, &self.state)?; @@ -235,12 +244,16 @@ impl SequencerCore { /// Builds a new block from transactions in the mempool. /// Does NOT publish or store the block — the caller is responsible for that. - pub fn build_block_from_mempool(&mut self) -> Result { + /// + /// Returns the built block together with bridge withdraw data extracted + /// from included bridge withdraw transactions. + fn build_block_from_mempool(&mut self) -> Result { let now = Instant::now(); let new_block_height = self.next_block_id(); let mut valid_transactions = vec![]; + let mut bridge_withdrawals = vec![]; let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64()) .expect("`max_block_size` should fit into usize"); @@ -305,6 +318,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 => { @@ -322,6 +339,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; @@ -355,7 +373,11 @@ impl SequencerCore { hashable_data.transactions.len(), now.elapsed().as_secs() ); - Ok(block) + + Ok(BlockWithMeta { + block, + bridge_withdrawals, + }) } pub const fn state(&self) -> &nssa::V03State { @@ -411,6 +433,17 @@ impl SequencerCore { } } +struct BlockWithMeta { + block: Block, + bridge_withdrawals: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BridgeWithdrawData { + pub amount: u64, + pub bedrock_account_pk: [u8; 32], +} + /// Builds the initial genesis state from `testnet_initial_state` plus configured genesis /// transactions. Returns the final state and the list of [`NSSATransaction`]s that should be /// committed to the genesis block so external observers can replay them. @@ -503,7 +536,7 @@ fn build_bridge_deposit_tx( bridge_core::Instruction::Deposit { vault_program_id, recipient_id: metadata.recipient_id, - amount: u128::from(deposit.amount), + amount: deposit.amount, }, ) .context("Failed to build bridge deposit message")?; @@ -515,6 +548,35 @@ fn build_bridge_deposit_tx( ))) } +#[must_use] +pub fn extract_bridge_withdraw_data(tx: &NSSATransaction) -> Option { + let NSSATransaction::Public(tx) = tx else { + return None; + }; + + let message = tx.message(); + if message.program_id != nssa::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() { diff --git a/sequencer/core/src/mock.rs b/sequencer/core/src/mock.rs index e041269a..0b0024ac 100644 --- a/sequencer/core/src/mock.rs +++ b/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(()) } }