Merge pull request #508 from logos-blockchain/arjentix/bridge-withdraw

feat(sequencer): implement bridge withdraw flow
This commit is contained in:
Daniil Polyakov 2026-06-17 01:01:04 +03:00 committed by GitHub
commit e6ad179641
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 926 additions and 158 deletions

7
Cargo.lock generated
View File

@ -4140,6 +4140,7 @@ dependencies = [
"bytesize",
"common",
"faucet_core",
"futures",
"hex",
"indexer_ffi",
"indexer_service_protocol",
@ -4150,6 +4151,9 @@ dependencies = [
"log",
"logos-blockchain-core",
"logos-blockchain-http-api-common",
"logos-blockchain-key-management-system-service",
"logos-blockchain-zone-sdk",
"num-bigint 0.4.6",
"reqwest",
"sequencer_core",
"sequencer_service_rpc",
@ -9011,6 +9015,7 @@ dependencies = [
"futures",
"hex",
"humantime-serde",
"key_protocol",
"lee",
"lee_core",
"log",
@ -9018,6 +9023,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",
@ -10874,6 +10880,7 @@ dependencies = [
"base58",
"bincode",
"bip39",
"bridge_core",
"clap",
"common",
"derive_more",

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"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -46,7 +46,7 @@ _wallet() {
cword=$COMP_CWORD
}
local commands="auth-transfer chain-info account pinata token amm ata vault check-health config restore-keys deploy-program help"
local commands="auth-transfer chain-info account pinata token amm ata vault bridge check-health config restore-keys deploy-program help"
# Find the main command and subcommand by scanning words before the cursor.
# Global options that take a value are skipped along with their argument.
@ -561,6 +561,24 @@ _wallet() {
;; # no specific completion
*)
COMPREPLY=($(compgen -W "--account-id --amount" -- "$cur"))
esac
;;
esac
;;
bridge)
case "$subcmd" in
"")
COMPREPLY=($(compgen -W "withdraw help" -- "$cur"))
;;
withdraw)
case "$prev" in
--from)
_wallet_complete_account_id "$cur"
;;
--amount | --bedrock-account-pk)
;; # no specific completion
*)
COMPREPLY=($(compgen -W "--from --amount --bedrock-account-pk" -- "$cur"))
;;
esac
;;

View File

@ -26,6 +26,7 @@ _wallet() {
'amm:AMM program interaction subcommand'
'ata:Associated Token Account program interaction subcommand'
'vault:Vault program interaction subcommand'
'bridge:Bridge program interaction subcommand'
'check-health:Check the wallet can connect to the node and builtin local programs match the remote versions'
'config:Command to setup config, get and set config fields'
'restore-keys:Restoring keys from given password at given depth'
@ -59,6 +60,8 @@ _wallet() {
;;
vault)
_wallet_vault
bridge)
_wallet_bridge
;;
config)
_wallet_config
@ -481,6 +484,35 @@ _wallet_vault() {
esac
}
# bridge subcommand
_wallet_bridge() {
local -a subcommands
_arguments -C \
'1: :->subcommand' \
'*:: :->args'
case $state in
subcommand)
subcommands=(
'withdraw:Withdraw native tokens through the bridge'
'help:Print this message or the help of the given subcommand(s)'
)
_describe -t subcommands 'bridge subcommands' subcommands
;;
args)
case $line[1] in
withdraw)
_arguments \
'--from[Sender account with privacy prefix]:from:_wallet_account_ids' \
'--amount[Amount of native tokens to withdraw]:amount:' \
'--bedrock-account-pk[Bedrock account public key (32-byte hex)]:bedrock_pk:'
;;
esac
;;
esac
}
# config subcommand
_wallet_config() {
local -a subcommands
@ -555,6 +587,7 @@ _wallet_help() {
'amm:AMM program interaction subcommand'
'ata:Associated Token Account program interaction subcommand'
'vault:Vault program interaction subcommand'
'bridge:Bridge program interaction subcommand'
'check-health:Check the wallet can connect to the node'
'config:Command to setup config, get and set config fields'
'restore-keys:Restoring keys from given password at given depth'

View File

@ -29,13 +29,17 @@ wallet-ffi.workspace = true
indexer_ffi.workspace = true
indexer_service_protocol.workspace = true
logos-blockchain-http-api-common.workspace = true
logos-blockchain-core.workspace = true
logos-blockchain-zone-sdk.workspace = true
logos-blockchain-key-management-system-service.workspace = true
anyhow.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
futures.workspace = true
hex.workspace = true
tempfile.workspace = true
bytesize.workspace = true
reqwest.workspace = true
borsh.workspace = true
logos-blockchain-http-api-common.workspace = true
logos-blockchain-core.workspace = true
num-bigint.workspace = true

View File

@ -9,6 +9,7 @@ use std::{ops::Deref as _, time::Duration};
use anyhow::Context as _;
use borsh::BorshSerialize;
use common::transaction::LeeTransaction;
use futures::StreamExt as _;
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, wait_for_indexer_to_catch_up,
};
@ -18,7 +19,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::{
@ -26,8 +27,14 @@ use logos_blockchain_http_api_common::bodies::{
transfer_funds::{WalletTransferFundsRequestBody, WalletTransferFundsResponseBody},
},
};
use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use num_bigint::BigUint;
use sequencer_service_rpc::RpcClient as _;
use test_fixtures::public_mention;
use tokio::test;
use wallet::cli::{Command, execute_subcommand, programs::bridge::BridgeSubcommand};
const TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK: Duration = Duration::from_mins(2);
@ -197,8 +204,9 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
async fn submit_bedrock_deposit(
bedrock_addr: std::net::SocketAddr,
bedrock_account_pk: &str,
recipient_id: AccountId,
amount: u128,
amount: u64,
) -> anyhow::Result<()> {
#[derive(BorshSerialize)]
struct DepositMetadata {
@ -211,18 +219,13 @@ async fn submit_bedrock_deposit(
.try_into()
.context("Encoded metadata is too big")?;
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();
let query_balance = || async {
let balance_response = client
.get(format!(
"http://{bedrock_addr}/wallet/{funding_key}/balance"
"http://{bedrock_addr}/wallet/{bedrock_account_pk}/balance"
))
.send()
.await
@ -239,13 +242,13 @@ async fn submit_bedrock_deposit(
let mut balance = query_balance().await?;
info!(
"Queried Bedrock balance for key {funding_key}: {:?}",
"Queried Bedrock balance for key {bedrock_account_pk}: {:?}",
balance.balance
);
if balance.balance < amount {
anyhow::bail!(
"Bedrock wallet with key {funding_key} has insufficient balance {:?} for deposit amount {:?}",
"Bedrock wallet with key {bedrock_account_pk} has insufficient balance {:?} for deposit amount {:?}",
balance.balance,
amount
);
@ -371,11 +374,18 @@ async fn wait_for_vault_balance(
})?
}
/// Test deposit and withdraw round trip.
///
/// Implemented as one test instead of two separate tests for deposit and withdraw, because the
/// withdraw test depends on the deposit to set up the necessary state (funds in vault) for testing
/// withdraw functionality.
#[test]
async fn bedrock_deposit_mints_to_vault_then_claim_succeeds() -> anyhow::Result<()> {
let ctx = TestContext::new().await?;
async fn bedrock_deposit_claim_and_withdraw_round_trip_succeeds() -> anyhow::Result<()> {
let mut ctx = TestContext::new().await?;
let bedrock_account_pk = "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26";
let recipient_id = ctx.existing_public_accounts()[0];
let amount = 1_u64;
let vault_program_id = Program::vault().id();
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
@ -389,10 +399,17 @@ async fn bedrock_deposit_mints_to_vault_then_claim_succeeds() -> anyhow::Result<
.await?;
// Submit deposit to Bedrock
submit_bedrock_deposit(ctx.bedrock_addr(), recipient_id, 1).await?;
submit_bedrock_deposit(ctx.bedrock_addr(), bedrock_account_pk, recipient_id, amount)
.await
.context("Failed to submit Bedrock deposit for round-trip setup")?;
// Wait for vault to receive the deposit (minted from bridge to vault)
wait_for_vault_balance(&ctx, recipient_vault_id, vault_balance_before + 1).await?;
wait_for_vault_balance(
&ctx,
recipient_vault_id,
vault_balance_before + u128::from(amount),
)
.await?;
// Now claim funds from vault back to recipient
let nonces = ctx
@ -412,7 +429,9 @@ async fn bedrock_deposit_mints_to_vault_then_claim_succeeds() -> anyhow::Result<
vault_program_id,
vec![recipient_id, recipient_vault_id],
nonces,
vault_core::Instruction::Claim { amount: 1 },
vault_core::Instruction::Claim {
amount: u128::from(amount),
},
)
.context("Failed to build vault claim message")?;
@ -447,7 +466,7 @@ async fn bedrock_deposit_mints_to_vault_then_claim_succeeds() -> anyhow::Result<
);
assert_eq!(
recipient_balance_after_claim,
recipient_balance_before + 1,
recipient_balance_before + u128::from(amount),
"Recipient balance should increase by claimed amount"
);
@ -472,5 +491,100 @@ async fn bedrock_deposit_mints_to_vault_then_claim_succeeds() -> anyhow::Result<
);
}
// Withdraw back to Bedrock and wait for finalized withdraw event.
let sender_id = recipient_id;
let observer = create_zone_indexer_observer(ctx.bedrock_addr())?;
let observe_fut = wait_for_finalized_withdraw_op(&observer, amount, bedrock_account_pk);
let withdraw_fut = execute_subcommand(
ctx.wallet_mut(),
Command::Bridge(BridgeSubcommand::Withdraw {
from: public_mention(sender_id),
amount,
bedrock_account_pk: bedrock_account_pk.to_owned(),
}),
);
let (observe_result, withdraw_result) = tokio::join!(observe_fut, withdraw_fut);
withdraw_result.context("Failed to execute wallet bridge withdraw command")?;
observe_result
.context("Failed while waiting for finalized withdraw event from zone indexer")?;
// Sleep to observe sequencer log about validated withdraw event
tokio::time::sleep(Duration::from_secs(1)).await;
Ok(())
}
fn create_zone_indexer_observer(
bedrock_addr: std::net::SocketAddr,
) -> anyhow::Result<ZoneIndexer<NodeHttpClient>> {
let bedrock_url = integration_tests::config::addr_to_url(
integration_tests::config::UrlProtocol::Http,
bedrock_addr,
)
.context("Failed to convert Bedrock addr to URL for zone indexer observer")?;
let node = NodeHttpClient::new(CommonHttpClient::new(None), bedrock_url);
Ok(ZoneIndexer::new(
integration_tests::config::bedrock_channel_id(),
node,
))
}
async fn wait_for_finalized_withdraw_op(
observer: &ZoneIndexer<NodeHttpClient>,
expected_amount: u64,
receiver_pk: &str,
) -> anyhow::Result<()> {
let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK
+ Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS);
let bedrock_account_pk_bytes = hex::decode(receiver_pk)
.context("Failed to decode expected receiver public key from hex")?;
let expected_receiver_pk =
logos_blockchain_key_management_system_service::keys::ZkPublicKey::from(
BigUint::from_bytes_le(&bedrock_account_pk_bytes),
);
tokio::time::timeout(timeout, async {
loop {
let stream = observer
.follow()
.await
.context("Failed to read zone indexer message batch")?;
let mut stream = std::pin::pin!(stream);
while let Some(message) = stream.next().await {
info!("Observed zone message {message:?}");
let ZoneMessage::Withdraw(withdraw) = message else {
continue;
};
let mut iter = withdraw.outputs.iter();
let Some(note) = iter.next() else {
continue;
};
if iter.next().is_some() {
// Withdraw op should only have one output
continue;
}
if note.value == expected_amount && note.pk == expected_receiver_pk {
return Ok(());
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
})
.await
.with_context(|| {
format!("Timed out waiting for finalized withdraw message with amount {expected_amount}")
})?
}

View File

@ -52,6 +52,7 @@ impl std::fmt::Debug for Commitment {
impl Commitment {
/// Generates the commitment to a private account owned by user for `account_id`:
/// SHA256( `Comm_DS` || `account_id` || `program_owner` || balance || nonce || SHA256(data)).
// TODO: Accept account_id by value as it's Copy
#[must_use]
pub fn new(account_id: &AccountId, account: &Account) -> Self {
const COMMITMENT_PREFIX: &[u8; 32] =

View File

@ -97,6 +97,7 @@ impl Nullifier {
}
/// Computes a nullifier for an account initialization.
// TODO: Accept account_id by value as it's Copy
#[must_use]
pub fn for_account_initialization(account_id: &AccountId) -> Self {
const INIT_PREFIX: &[u8; 32] = b"/LEE/v0.3/Nullifier/Initialize/\x00";

View File

@ -80,15 +80,16 @@ impl LeeTransaction {
) -> Result<ValidatedStateDiff, lee::error::LeeError> {
let diff = self.compute_state_diff(state, block_id, timestamp)?;
// system accounts guard
let system_accounts = lee::CLOCK_PROGRAM_ACCOUNT_IDS.iter().copied().chain([
lee::system_faucet_account_id(),
lee::system_bridge_account_id(),
]);
for account_id in system_accounts {
let restricted_modification_accounts = lee::CLOCK_PROGRAM_ACCOUNT_IDS
.iter()
.copied()
.chain(std::iter::once(lee::system_faucet_account_id()));
for account_id in restricted_modification_accounts {
validate_doesnt_modify_account(state, &diff, account_id)?;
}
self.validate_bridge_account_modification(state, &diff)?;
Ok(diff)
}
@ -150,6 +151,40 @@ 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 only_balance_increased = {
let expected_pre = lee::Account {
balance: pre.balance,
..post.clone()
};
(expected_pre == pre) && (pre.balance <= post.balance)
};
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]
@ -46,3 +47,4 @@ mock = []
futures.workspace = true
test_program_methods.workspace = true
lee = { workspace = true, features = ["test-utils"] }
key_protocol.workspace = true

View File

@ -2,15 +2,18 @@ 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 log::{info, warn};
use logos_blockchain_core::mantle::ops::channel::inscribe::Inscription;
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,
adapter::NodeHttpClient,
sequencer::{Event, SequencerConfig as ZoneSdkSequencerConfig, SequencerHandle, ZoneSequencer},
state::{DepositInfo, FinalizedOp, InscriptionInfo},
sequencer::{
Event, SequencerConfig as ZoneSdkSequencerConfig, SequencerHandle, WithdrawArg,
ZoneSequencer,
},
state::{DepositInfo, FinalizedOp, InscriptionInfo, WithdrawInfo},
};
use tokio::task::JoinHandle;
@ -29,8 +32,16 @@ pub type FinalizedBlockSink = Box<dyn Fn(u64) + Send + 'static>;
pub type OnDepositEventSink =
Box<dyn Fn(DepositInfo) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
/// Sink for finalized Bedrock withdraw events.
pub type OnWithdrawEventSink =
Box<dyn Fn(WithdrawInfo) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
pub trait BlockPublisherTrait: Clone {
#[expect(
clippy::too_many_arguments,
reason = "Looks better than bundling all those callbacks into a struct"
)]
async fn new(
config: &BedrockConfig,
bedrock_signing_key: Ed25519Key,
@ -39,11 +50,12 @@ pub trait BlockPublisherTrait: Clone {
on_checkpoint: CheckpointSink,
on_finalized_block: FinalizedBlockSink,
on_deposit_event: OnDepositEventSink,
on_withdraw_event: OnWithdrawEventSink,
) -> Result<Self>;
/// 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, withdrawals: Vec<WithdrawArg>) -> Result<()>;
}
/// Real block publisher backed by zone-sdk's `ZoneSequencer`.
@ -71,6 +83,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
on_checkpoint: CheckpointSink,
on_finalized_block: FinalizedBlockSink,
on_deposit_event: OnDepositEventSink,
on_withdraw_event: OnWithdrawEventSink,
) -> Result<Self> {
let basic_auth = config.auth.clone().map(Into::into);
let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), config.node_url.clone());
@ -107,7 +120,9 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
FinalizedOp::Deposit(deposit) => {
on_deposit_event(deposit).await;
}
FinalizedOp::Withdraw(_) => {}
FinalizedOp::Withdraw(withdraw) => {
on_withdraw_event(withdraw).await;
}
}
}
}
@ -127,16 +142,33 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
})
}
async fn publish_block(&self, block: &Block) -> Result<()> {
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<()> {
let data = borsh::to_vec(block).context("Failed to serialize block")?;
let data_bounded = data
let data_bounded: Inscription = data
.try_into()
.context("Block data exceeds maximum allowed size")?;
let data_byte_size = data_bounded.len();
if withdrawals.is_empty() {
self.handle
.publish_message(data_bounded)
.await
.context("Failed to publish block")?;
info!("Published block with the size of {data_byte_size} bytes");
return Ok(());
}
let withdraw_count = withdrawals.len();
self.handle
.publish_message(data_bounded)
.publish_atomic_withdraw(data_bounded, withdrawals)
.await
.context("Failed to publish block")?;
.context("Failed to publish block with withdrawals")?;
info!(
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
);
Ok(())
}

View File

@ -10,7 +10,10 @@ use lee::V03State;
use log::info;
use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
pub use storage::DbResult;
use storage::sequencer::{RocksDBIO, sequencer_cells::PendingDepositEventRecord};
use storage::sequencer::{
RocksDBIO,
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey},
};
pub struct SequencerStore {
dbio: Arc<RocksDBIO>,
@ -128,9 +131,16 @@ impl SequencerStore {
self.dbio.get_all_blocks()
}
pub(crate) fn update(&mut self, block: &Block, state: &V03State) -> DbResult<()> {
pub(crate) fn update(
&mut self,
block: &Block,
deposit_event_ids: &[HashType],
withdrawals: Vec<WithdrawalReconciliationKey>,
state: &V03State,
) -> DbResult<()> {
let new_transactions_map = block_to_transactions_map(block);
self.dbio.atomic_update(block, state)?;
self.dbio
.atomic_update(block, deposit_event_ids, withdrawals, state)?;
self.tx_hash_to_block_map.extend(new_transactions_map);
Ok(())
}
@ -158,23 +168,6 @@ impl SequencerStore {
pub fn get_unfulfilled_deposit_events(&self) -> DbResult<Vec<PendingDepositEventRecord>> {
self.dbio.get_pending_deposit_events()
}
pub fn mark_unfulfilled_deposit_events_submitted(
&self,
deposit_op_ids: &[HashType],
submitted_block_id: u64,
) -> DbResult<usize> {
self.dbio
.mark_pending_deposit_events_submitted(deposit_op_ids, submitted_block_id)
}
pub fn remove_fulfilled_unfulfilled_deposit_events_up_to_block(
&self,
finalized_block_id: u64,
) -> DbResult<usize> {
self.dbio
.remove_fulfilled_pending_deposit_events_up_to_block(finalized_block_id)
}
}
pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64> {
@ -227,7 +220,9 @@ mod tests {
assert_eq!(None, retrieved_tx);
// Add the block with the transaction
let dummy_state = V03State::new_with_genesis_accounts(&[], vec![], 0);
node_store.update(&block, &dummy_state).unwrap();
node_store
.update(&block, &[], vec![], &dummy_state)
.unwrap();
// Try again
let retrieved_tx = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some(tx), retrieved_tx);
@ -292,7 +287,9 @@ mod tests {
let block_hash = block.header.hash;
let dummy_state = V03State::new_with_genesis_accounts(&[], vec![], 0);
node_store.update(&block, &dummy_state).unwrap();
node_store
.update(&block, &[], vec![], &dummy_state)
.unwrap();
// Verify that the latest block meta now equals the new block's hash
let latest_meta = node_store.latest_block_meta().unwrap();
@ -328,7 +325,9 @@ mod tests {
let block_id = block.header.block_id;
let dummy_state = V03State::new_with_genesis_accounts(&[], vec![], 0);
node_store.update(&block, &dummy_state).unwrap();
node_store
.update(&block, &[], vec![], &dummy_state)
.unwrap();
// Verify initial status is Pending
let retrieved_block = node_store.get_block_at_id(block_id).unwrap().unwrap();
@ -377,7 +376,12 @@ mod tests {
// Add a new block
let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]);
node_store
.update(&block, &V03State::new_with_genesis_accounts(&[], vec![], 0))
.update(
&block,
&[],
vec![],
&V03State::new_with_genesis_accounts(&[], vec![], 0),
)
.unwrap();
}

View File

@ -12,11 +12,16 @@ use lee::{AccountId, PublicTransaction, program::Program, public_transaction::Me
use lee_core::GENESIS_BLOCK_ID;
use log::{error, info, warn};
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use logos_blockchain_zone_sdk::sequencer::WithdrawArg;
use mempool::{MemPool, MemPoolHandle};
#[cfg(feature = "mock")]
pub use mock::SequencerCoreWithMockClients;
use num_bigint::BigUint;
pub use storage::error::DbError;
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
use storage::sequencer::{
RocksDBIO,
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey},
};
use crate::{
block_publisher::{BlockPublisherTrait, ZoneSdkPublisher},
@ -126,8 +131,47 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.expect("Failed to load zone-sdk checkpoint");
let is_fresh_start = initial_checkpoint.is_none();
let dbio_for_checkpoint = store.dbio();
let on_checkpoint: block_publisher::CheckpointSink = Box::new(move |cp| {
let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size);
replay_unfulfilled_deposit_events(&store, mempool_handle.clone());
let block_publisher = BP::new(
&config.bedrock_config,
bedrock_signing_key,
config.retry_pending_blocks_timeout,
initial_checkpoint,
Self::on_checkpoint(store.dbio()),
Self::on_finalized_block(store.dbio()),
Self::on_deposit_event(store.dbio(), mempool_handle.clone()),
Self::on_withdraw_event(store.dbio()),
)
.await
.expect("Failed to initialize Block Publisher");
// On a truly fresh start (no checkpoint persisted yet), publish the
// 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 {
block_publisher
.publish_block(&genesis_block, vec![])
.await
.expect("Failed to publish genesis block");
}
let sequencer_core = Self {
state,
store,
mempool,
chain_height: latest_block_meta.id,
sequencer_config: config,
block_publisher,
};
(sequencer_core, mempool_handle)
}
fn on_checkpoint(dbio: Arc<RocksDBIO>) -> block_publisher::CheckpointSink {
Box::new(move |cp| {
let bytes = match serde_json::to_vec(&cp) {
Ok(b) => b,
Err(err) => {
@ -135,24 +179,25 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
return;
}
};
if let Err(err) = dbio_for_checkpoint.put_zone_sdk_checkpoint_bytes(&bytes) {
if let Err(err) = dbio.put_zone_sdk_checkpoint_bytes(&bytes) {
error!("Failed to persist zone-sdk checkpoint: {err:#}");
}
});
})
}
let dbio_for_finalized = store.dbio();
let on_finalized_block: block_publisher::FinalizedBlockSink = Box::new(move |block_id| {
fn on_finalized_block(dbio: Arc<RocksDBIO>) -> block_publisher::FinalizedBlockSink {
Box::new(move |block_id| {
// NOTE: Theoretically Zone SDK may report finalization happening multiple times for the
// same block. In practice this is very unlikely to happen. For that to
// happen Sequencer should crash between receiving Finalized and Checkpoint events while
// these events happen very fast (because Checkpoints are generated by Zone SDK
// locally).
if let Err(err) = dbio_for_finalized.clean_pending_blocks_up_to(block_id) {
if let Err(err) = dbio.clean_pending_blocks_up_to(block_id) {
error!("Failed to mark pending blocks finalized up to {block_id}: {err:#}");
}
match dbio_for_finalized.remove_fulfilled_pending_deposit_events_up_to_block(block_id) {
match dbio.remove_fulfilled_pending_deposit_events_up_to_block(block_id) {
Ok(0) => {}
Ok(removed) => {
info!(
@ -165,29 +210,29 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
);
}
}
});
})
}
let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size);
replay_unfulfilled_deposit_events(&store, mempool_handle.clone());
let mempool_handle_for_deposit = mempool_handle.clone();
let dbio_for_deposit = store.dbio();
let on_deposit_event: block_publisher::OnDepositEventSink = Box::new(move |deposit| {
fn on_deposit_event(
dbio: Arc<RocksDBIO>,
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
) -> block_publisher::OnDepositEventSink {
Box::new(move |deposit| {
// NOTE: Theoretically Zone SDK may report multiple identical deposits. In practice this
// is very unlikely to happen. For that to happen Sequencer should crash
// between receiving Deposit and Checkpoint events while these events happen
// very fast (because Checkpoints are generated by Zone SDK locally).
let dbio_for_deposit = Arc::clone(&dbio_for_deposit);
let mempool_handle_for_deposit = mempool_handle_for_deposit.clone();
let dbio = Arc::clone(&dbio);
let mempool_handle = mempool_handle.clone();
Box::pin(async move {
let id_hex = hex::encode(deposit.op_id);
info!("Observed Bedrock Deposit event with id: {id_hex}");
let event_record = pending_deposit_event_record(&deposit);
match dbio_for_deposit.add_pending_deposit_event(event_record.clone()) {
match dbio.add_pending_deposit_event(event_record.clone()) {
Ok(true) => {}
Ok(false) => {
info!(
@ -213,7 +258,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
};
if let Err(err) = mempool_handle_for_deposit
if let Err(err) = mempool_handle
.push((TransactionOrigin::Sequencer, tx))
.await
{
@ -222,69 +267,66 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
);
}
})
});
})
}
let block_publisher = BP::new(
&config.bedrock_config,
bedrock_signing_key,
config.retry_pending_blocks_timeout,
initial_checkpoint,
on_checkpoint,
on_finalized_block,
on_deposit_event,
)
.await
.expect("Failed to initialize Block Publisher");
fn on_withdraw_event(dbio: Arc<RocksDBIO>) -> block_publisher::OnWithdrawEventSink {
Box::new(move |withdraw| {
let dbio = Arc::clone(&dbio);
Box::pin(async move {
let hash_encoded = hex::encode(withdraw.tx_hash.as_ref());
let withdraw_key = match withdraw_event_reconciliation_key(&withdraw.op.outputs) {
Ok(key) => key,
Err(err) => {
error!(
"Failed to build reconciliation key for Bedrock Withdraw event with tx_hash {hash_encoded}: {err:#}"
);
return;
}
};
// On a truly fresh start (no checkpoint persisted yet), publish the
// 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 {
block_publisher
.publish_block(&genesis_block)
.await
.expect("Failed to publish genesis block");
}
let sequencer_core = Self {
state,
store,
mempool,
chain_height: latest_block_meta.id,
sequencer_config: config,
block_publisher,
};
(sequencer_core, mempool_handle)
match dbio.consume_unseen_withdraw_count(withdraw_key) {
Ok(true) => {
info!("Validated Bedrock Withdraw event with tx_hash: {hash_encoded}");
}
Ok(false) => warn!(
"Unexpected Bedrock Withdraw event with tx_hash {hash_encoded}: no matching unseen withdraw found"
),
Err(err) => error!(
"Failed to reconcile Bedrock Withdraw event with tx_hash {hash_encoded}: {err:#}"
),
}
})
})
}
/// 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;
withdrawals,
} = self
.build_block_from_mempool()
.context("Failed to build block from mempool transactions")?;
let withdrawal_reconciliation_keys = withdrawals
.iter()
.map(|withdraw| withdraw_event_reconciliation_key(&withdraw.outputs))
.collect::<Result<_>>()
.context("Failed to build reconciliation keys for block withdrawals")?;
self.block_publisher
.publish_block(&block)
.publish_block(&block, withdrawals)
.await
.context("Failed to publish block to Bedrock")?;
self.store.update(&block, &self.state)?;
let updated_deposits = self
.store
.mark_unfulfilled_deposit_events_submitted(&deposit_event_ids, block.header.block_id)?;
if updated_deposits > 0 {
info!(
"Marked {updated_deposits} pending deposit events as submitted in block {}",
block.header.block_id
);
}
self.store.update(
&block,
&deposit_event_ids,
withdrawal_reconciliation_keys,
&self.state,
)?;
Ok(self.chain_height)
}
@ -298,6 +340,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let mut valid_transactions = Vec::new();
let mut deposit_event_ids = Vec::new();
let mut 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");
@ -362,6 +405,10 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
};
if let Some(withdraw_data) = extract_bridge_withdraw_data(&tx) {
withdrawals.push(withdraw_data);
}
self.state.apply_state_diff(validated_diff);
}
TransactionOrigin::Sequencer => {
@ -369,17 +416,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
@ -393,6 +431,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;
@ -427,6 +466,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
Ok(BlockWithMeta {
block,
deposit_event_ids,
withdrawals,
})
}
@ -486,6 +526,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
struct BlockWithMeta {
block: Block,
deposit_event_ids: Vec<HashType>,
withdrawals: Vec<WithdrawArg>,
}
/// Checks the database for any pending deposit events that have not yet been marked as submitted in
@ -640,7 +681,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")?;
@ -652,6 +693,97 @@ 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<WithdrawArg> {
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,
} => {
let recipient_pk =
logos_blockchain_key_management_system_service::keys::ZkPublicKey::from(
BigUint::from_bytes_le(&bedrock_account_pk),
);
Some(WithdrawArg {
outputs: logos_blockchain_core::mantle::ledger::Outputs::new(
logos_blockchain_core::mantle::Note::new(amount, recipient_pk),
),
})
}
bridge_core::Instruction::Deposit { .. } => unreachable!(
"Deposit instructions from users should never pass validation, and thus should never be seen here"
),
}
}
fn withdraw_event_reconciliation_key(
outputs: &logos_blockchain_core::mantle::ledger::Outputs,
) -> Result<WithdrawalReconciliationKey> {
let [note] = outputs.as_ref().as_slice() else {
return Err(anyhow!(
"Unsupported withdraw output count for reconciliation: {}",
outputs.len()
));
};
// `extract_bridge_withdraw_data` maps [u8;32] LE -> BigUint -> ZkPublicKey.
// Reconcile by reversing that direction here.
let mut bedrock_account_pk = BigUint::from(note.pk.into_inner()).to_bytes_le();
if bedrock_account_pk.len() > 32 {
return Err(anyhow!(
"Withdraw recipient public key is too large: {} bytes",
bedrock_account_pk.len()
));
}
bedrock_account_pk.resize(32, 0);
let bedrock_account_pk: [u8; 32] = bedrock_account_pk
.try_into()
.expect("Public key bytes were padded/truncated to 32 bytes");
Ok(WithdrawalReconciliationKey {
amount: note.value,
bedrock_account_pk,
})
}
/// 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() {
@ -687,6 +819,20 @@ mod tests {
test_utils::sequencer_sign_key_for_testing,
transaction::{LeeTransaction, clock_invocation},
};
use key_protocol::key_management::KeyChain;
use lee::{
Account, AccountId, Data, EphemeralPublicKey, PrivacyPreservingTransaction,
SharedSecretKey, V03State,
error::LeeError,
execute_and_prove,
privacy_preserving_transaction::{Message, circuit::ProgramWithDependencies},
program::Program,
system_bridge_account_id,
};
use lee_core::{
Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier,
account::{AccountWithMetadata, Nonce},
};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use mempool::MemPoolHandle;
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
@ -788,7 +934,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
)
}
@ -1457,4 +1603,95 @@ mod tests {
"Block production should abort when clock account data is corrupted"
);
}
#[test]
fn private_bridge_withdraw_invocation_is_dropped() {
let sender_keys = KeyChain::new_os_random();
let sender_account_id =
AccountId::for_regular_private_account(&sender_keys.nullifier_public_key, 0);
let sender_private_account = Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: 100,
nonce: Nonce(0xdead_beef),
data: Data::default(),
};
let mut state = V03State::new_with_genesis_accounts(
&[],
vec![(
Commitment::new(&sender_account_id, &sender_private_account),
Nullifier::for_account_initialization(&sender_account_id),
)],
0,
);
let sender_commitment = Commitment::new(&sender_account_id, &sender_private_account);
let bridge_account_id = system_bridge_account_id();
let sender_pre = AccountWithMetadata::new(
sender_private_account,
true,
(&sender_keys.nullifier_public_key, 0),
);
let bridge_pre = AccountWithMetadata::new(
state.get_account_by_id(bridge_account_id),
false,
bridge_account_id,
);
let shared_secret = SharedSecretKey::encapsulate(&sender_keys.viewing_public_key).0;
let instruction = Program::serialize_instruction(bridge_core::Instruction::Withdraw {
amount: 1,
bedrock_account_pk: [0; 32],
})
.unwrap();
let program_with_deps = ProgramWithDependencies::new(
Program::bridge(),
[(
Program::authenticated_transfer_program().id(),
Program::authenticated_transfer_program(),
)]
.into(),
);
let (output, proof) = execute_and_prove(
vec![sender_pre, bridge_pre],
instruction,
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
epk: EphemeralPublicKey(vec![12_u8; 1088]),
view_tag: EncryptedAccountData::compute_view_tag(
&sender_keys.nullifier_public_key,
&sender_keys.viewing_public_key,
),
ssk: shared_secret,
nsk: sender_keys.private_key_holder.nullifier_secret_key,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
.expect("sender commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::Public,
],
&program_with_deps,
)
.expect("Execution should succeed");
let message = Message::try_from_circuit_output(vec![bridge_account_id], vec![], output)
.expect("Message construction should succeed");
let witness_set =
lee::privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]);
let tx = LeeTransaction::PrivacyPreserving(PrivacyPreservingTransaction::new(
message,
witness_set,
));
let res = tx.execute_check_on_state(&mut state, 1, 0);
assert!(
matches!(res, Err(LeeError::InvalidInput(_))),
"Bridge withdraw invocation should be rejected in private execution"
);
}
}

View File

@ -3,11 +3,12 @@ use std::time::Duration;
use anyhow::Result;
use common::block::Block;
use logos_blockchain_key_management_system_service::keys::Ed25519Key;
use logos_blockchain_zone_sdk::sequencer::WithdrawArg;
use crate::{
block_publisher::{
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink,
SequencerCheckpoint,
OnWithdrawEventSink, SequencerCheckpoint,
},
config::BedrockConfig,
};
@ -26,11 +27,16 @@ impl BlockPublisherTrait for MockBlockPublisher {
_on_checkpoint: CheckpointSink,
_on_finalized_block: FinalizedBlockSink,
_on_deposit_event: OnDepositEventSink,
_on_withdraw_event: OnWithdrawEventSink,
) -> Result<Self> {
Ok(Self)
}
async fn publish_block(&self, _block: &Block) -> Result<()> {
async fn publish_block(
&self,
_block: &Block,
_bridge_withdrawals: Vec<WithdrawArg>,
) -> Result<()> {
Ok(())
}
}

View File

@ -11,12 +11,16 @@ use rocksdb::{
use crate::{
CF_BLOCK_NAME, CF_META_NAME, DB_META_FIRST_BLOCK_IN_DB_KEY, DBIO, DbResult,
cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell},
cells::{
SimpleStorableCell,
shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell},
},
error::DbError,
sequencer::sequencer_cells::{
LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned,
LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned,
PendingDepositEventsCellRef, ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef,
PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey,
ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef,
},
};
@ -31,6 +35,8 @@ pub const DB_META_ZONE_SDK_CHECKPOINT_KEY: &str = "zone_sdk_checkpoint";
/// Key base for storing queued deposit events that were not yet
/// fulfilled on L2.
pub const DB_META_PENDING_DEPOSIT_EVENTS_KEY: &str = "pending_deposit_events";
/// Key base for counting unseen L2 withdraw intents.
pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count";
/// Key base for storing the LEE state.
pub const DB_LEE_STATE_KEY: &str = "lee_state";
@ -250,6 +256,14 @@ impl RocksDBIO {
self.put(&PendingDepositEventsCellRef(records), ())
}
fn put_pending_deposit_events_batch(
&self,
records: &[PendingDepositEventRecord],
batch: &mut WriteBatch,
) -> DbResult<()> {
self.put_batch(&PendingDepositEventsCellRef(records), (), batch)
}
pub fn add_pending_deposit_event(&self, event: PendingDepositEventRecord) -> DbResult<bool> {
let mut records = self.get_pending_deposit_events()?;
if records
@ -263,10 +277,11 @@ impl RocksDBIO {
Ok(true)
}
pub fn mark_pending_deposit_events_submitted(
fn mark_pending_deposit_events_submitted(
&self,
deposit_op_ids: &[HashType],
submitted_block_id: u64,
batch: &mut WriteBatch,
) -> DbResult<usize> {
let mut records = self.get_pending_deposit_events()?;
let mut updated: usize = 0;
@ -280,7 +295,7 @@ impl RocksDBIO {
}
if updated > 0 {
self.put_pending_deposit_events(&records)?;
self.put_pending_deposit_events_batch(&records, batch)?;
}
Ok(updated)
@ -306,6 +321,53 @@ impl RocksDBIO {
Ok(removed)
}
fn increment_unseen_withdraw_count(
&self,
withdrawal: WithdrawalReconciliationKey,
batch: &mut WriteBatch,
) -> DbResult<u64> {
let current = self
.get_opt::<UnseenWithdrawCountCell>(withdrawal)?
.map_or(0, |cell| cell.0);
let next = current.checked_add(1).ok_or_else(|| {
DbError::db_interaction_error("Unseen withdraw counter overflow".to_owned())
})?;
self.put_batch(&UnseenWithdrawCountCell(next), withdrawal, batch)?;
Ok(next)
}
pub fn consume_unseen_withdraw_count(
&self,
withdrawal: WithdrawalReconciliationKey,
) -> DbResult<bool> {
let Some(current) = self
.get_opt::<UnseenWithdrawCountCell>(withdrawal)?
.map(|cell| cell.0)
else {
return Ok(false);
};
if let Some(next) = current.checked_sub(1) {
self.put(&UnseenWithdrawCountCell(next), withdrawal)?;
} else {
let cf_meta = self.meta_column();
let db_key =
<UnseenWithdrawCountCell as SimpleStorableCell>::key_constructor(withdrawal)?;
self.db.delete_cf(&cf_meta, db_key).map_err(|rerr| {
DbError::rocksdb_cast_message(
rerr,
Some("Failed to delete unseen withdraw count".to_owned()),
)
})?;
}
Ok(true)
}
pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> {
let cf_block = self.block_column();
@ -439,11 +501,26 @@ impl RocksDBIO {
})
}
pub fn atomic_update(&self, block: &Block, state: &V03State) -> DbResult<()> {
pub fn atomic_update(
&self,
block: &Block,
deposit_op_ids: &[HashType],
withdrawals: Vec<WithdrawalReconciliationKey>,
state: &V03State,
) -> DbResult<()> {
let block_id = block.header.block_id;
let mut batch = WriteBatch::default();
self.put_block(block, false, &mut batch)?;
self.mark_pending_deposit_events_submitted(deposit_op_ids, block_id, &mut batch)?;
for withdrawal in withdrawals {
self.increment_unseen_withdraw_count(withdrawal, &mut batch)?;
}
self.put_lee_state_in_db_batch(state, &mut batch)?;
self.db.write(batch).map_err(|rerr| {
DbError::rocksdb_cast_message(
rerr,

View File

@ -9,7 +9,7 @@ use crate::{
sequencer::{
CF_LEE_STATE_NAME, DB_LEE_STATE_KEY, DB_META_LAST_FINALIZED_BLOCK_ID,
DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY,
DB_META_ZONE_SDK_CHECKPOINT_KEY,
DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY,
},
};
@ -175,6 +175,52 @@ impl SimpleWritableCell for PendingDepositEventsCellRef<'_> {
}
}
#[derive(Debug, Clone, Copy)]
pub struct WithdrawalReconciliationKey {
pub amount: u64,
pub bedrock_account_pk: [u8; 32],
}
#[derive(Debug, BorshSerialize, BorshDeserialize)]
pub struct UnseenWithdrawCountCell(pub u64);
impl SimpleStorableCell for UnseenWithdrawCountCell {
type KeyParams = WithdrawalReconciliationKey;
const CELL_NAME: &'static str = DB_META_UNSEEN_WITHDRAW_COUNT_KEY;
const CF_NAME: &'static str = CF_META_NAME;
fn key_constructor(key_params: Self::KeyParams) -> DbResult<Vec<u8>> {
let WithdrawalReconciliationKey {
amount,
bedrock_account_pk,
} = key_params;
borsh::to_vec(&(Self::CELL_NAME, amount, bedrock_account_pk)).map_err(|err| {
DbError::borsh_cast_message(
err,
Some(format!(
"Failed to serialize {:?} key params",
Self::CELL_NAME
)),
)
})
}
}
impl SimpleReadableCell for UnseenWithdrawCountCell {}
impl SimpleWritableCell for UnseenWithdrawCountCell {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(
err,
Some("Failed to serialize unseen withdraw count".to_owned()),
)
})
}
}
#[cfg(test)]
mod uniform_tests {
use crate::{

View File

@ -14,16 +14,18 @@ common.workspace = true
authenticated_transfer_core.workspace = true
key_protocol.workspace = true
sequencer_service_rpc = { workspace = true, features = ["client"] }
token_core.workspace = true
amm_core.workspace = true
testnet_initial_state.workspace = true
token_core.workspace = true
ata_core.workspace = true
vault_core.workspace = true
bridge_core.workspace = true
keycard_wallet.workspace = true
bip39.workspace = true
pyo3.workspace = true
rpassword = "7"
zeroize.workspace = true
keycard_wallet.workspace = true
anyhow.workspace = true
thiserror.workspace = true

View File

@ -20,7 +20,7 @@ use crate::{
group::GroupSubcommand,
keycard::KeycardSubcommand,
programs::{
amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand,
amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand, bridge::BridgeSubcommand,
native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand,
token::TokenProgramAgnosticSubcommand, vault::VaultSubcommand,
},
@ -68,6 +68,9 @@ pub enum Command {
/// Vault program interaction subcommand.
#[command(subcommand)]
Vault(VaultSubcommand),
/// Bridge program interaction subcommand.
#[command(subcommand)]
Bridge(BridgeSubcommand),
/// Group key management (create, invite, join, derive keys).
#[command(subcommand)]
Group(GroupSubcommand),
@ -258,6 +261,9 @@ pub async fn execute_subcommand(
Command::AMM(amm_subcommand) => amm_subcommand.handle_subcommand(wallet_core).await?,
Command::Ata(ata_subcommand) => ata_subcommand.handle_subcommand(wallet_core).await?,
Command::Vault(vault_subcommand) => vault_subcommand.handle_subcommand(wallet_core).await?,
Command::Bridge(bridge_subcommand) => {
bridge_subcommand.handle_subcommand(wallet_core).await?
}
Command::Group(group_subcommand) => group_subcommand.handle_subcommand(wallet_core).await?,
Command::Keycard(keycard_subcommand) => {
keycard_subcommand.handle_subcommand(wallet_core).await?

View File

@ -0,0 +1,64 @@
use anyhow::{Context as _, Result};
use clap::Subcommand;
use crate::{
WalletCore,
account::AccountIdWithPrivacy,
cli::{CliAccountMention, SubcommandReturnValue, WalletSubcommand},
program_facades::bridge::Bridge,
};
/// Represents generic CLI subcommand for a wallet working with bridge program.
#[derive(Subcommand, Debug, Clone)]
pub enum BridgeSubcommand {
/// Withdraw native tokens from a public account to Bedrock through the bridge.
Withdraw {
/// Sender account mention - account id with privacy prefix or a label.
#[arg(long)]
from: CliAccountMention,
/// Amount of native tokens to withdraw.
#[arg(long)]
amount: u64,
/// Bedrock account public key encoded as a 32-byte hex string.
#[arg(long)]
bedrock_account_pk: String,
},
}
impl WalletSubcommand for BridgeSubcommand {
async fn handle_subcommand(
self,
wallet_core: &mut WalletCore,
) -> Result<SubcommandReturnValue> {
match self {
Self::Withdraw {
from,
amount,
bedrock_account_pk,
} => {
let from = from.resolve(wallet_core.storage())?;
let AccountIdWithPrivacy::Public(sender_account_id) = from else {
anyhow::bail!("Bridge withdraw supports only public sender accounts");
};
let bedrock_account_pk = parse_bedrock_account_pk(&bedrock_account_pk)?;
let tx_hash = Bridge(wallet_core)
.send_withdraw(sender_account_id, amount, bedrock_account_pk)
.await?;
println!("Transaction hash is {tx_hash}");
Ok(SubcommandReturnValue::Empty)
}
}
}
}
fn parse_bedrock_account_pk(raw: &str) -> Result<[u8; 32]> {
let raw = raw.strip_prefix("0x").unwrap_or(raw);
let mut bedrock_account_pk = [0_u8; 32];
hex::decode_to_slice(raw, &mut bedrock_account_pk)
.context("Invalid `bedrock-account-pk`: expected hex string of 32 bytes")?;
Ok(bedrock_account_pk)
}

View File

@ -1,5 +1,6 @@
pub mod amm;
pub mod ata;
pub mod bridge;
pub mod native_token_transfer;
pub mod pinata;
pub mod token;

View File

@ -0,0 +1,35 @@
use common::HashType;
use lee::{AccountId, program::Program};
use crate::{AccountIdentity, ExecutionFailureKind, WalletCore};
pub struct Bridge<'wallet>(pub &'wallet WalletCore);
impl Bridge<'_> {
pub async fn send_withdraw(
&self,
sender_account_id: AccountId,
amount: u64,
bedrock_account_pk: [u8; 32],
) -> Result<HashType, ExecutionFailureKind> {
let program = Program::bridge();
let bridge_account_id = lee::system_bridge_account_id();
let instruction = bridge_core::Instruction::Withdraw {
amount,
bedrock_account_pk,
};
let instruction_data =
Program::serialize_instruction(instruction).expect("Instruction should serialize");
self.0
.send_pub_tx(
vec![
AccountIdentity::Public(sender_account_id),
AccountIdentity::PublicNoSign(bridge_account_id),
],
instruction_data,
&program.into(),
)
.await
}
}

View File

@ -3,6 +3,7 @@
pub mod amm;
pub mod ata;
pub mod bridge;
pub mod native_token_transfer;
pub mod pinata;
pub mod token;

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