feat(sequencer): implement bridge withdraw flow

This commit is contained in:
Daniil Polyakov 2026-06-02 00:42:41 +03:00
parent feb6cb7f92
commit 9e3ec3ad0e
10 changed files with 221 additions and 42 deletions

1
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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();

View File

@ -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<lee::PublicTransaction> for LeeTransaction {

View File

@ -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]

View File

@ -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<BridgeWithdrawData>,
) -> 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<BridgeWithdrawData>,
) -> 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(())
}
}

View File

@ -248,7 +248,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
// 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<BP: BlockPublisherTrait> SequencerCore<BP> {
/// Produces a new block from mempool transactions and publishes it via zone-sdk.
pub async fn produce_new_block(&mut self) -> Result<u64> {
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<BP: BlockPublisherTrait> SequencerCore<BP> {
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<BP: BlockPublisherTrait> SequencerCore<BP> {
}
};
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<BP: BlockPublisherTrait> SequencerCore<BP> {
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<BP: BlockPublisherTrait> SequencerCore<BP> {
}
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<BP: BlockPublisherTrait> SequencerCore<BP> {
Ok(BlockWithMeta {
block,
deposit_event_ids,
bridge_withdrawals,
})
}
@ -499,6 +497,13 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
struct BlockWithMeta {
block: Block,
deposit_event_ids: Vec<HashType>,
bridge_withdrawals: Vec<BridgeWithdrawData>,
}
#[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<HashType> {
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::<bridge_core::Instruction, u32>(&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<BridgeWithdrawData> {
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::<bridge_core::Instruction, u32>(&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<Ed25519Key> {
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
)
}

View File

@ -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<BridgeWithdrawData>,
) -> Result<()> {
Ok(())
}
}

View File

@ -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(

View File

@ -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],
},
}