feat(wallet): add bridge withdraw command

This commit is contained in:
Daniil Polyakov 2026-06-02 18:51:03 +03:00
parent 619b087d57
commit b9d9c802e9
12 changed files with 297 additions and 21 deletions

3
Cargo.lock generated
View File

@ -4102,6 +4102,7 @@ dependencies = [
"bytesize",
"common",
"faucet_core",
"futures",
"hex",
"indexer_ffi",
"indexer_service_protocol",
@ -4110,6 +4111,7 @@ dependencies = [
"log",
"logos-blockchain-core",
"logos-blockchain-http-api-common",
"logos-blockchain-zone-sdk",
"nssa",
"nssa_core",
"reqwest",
@ -10682,6 +10684,7 @@ dependencies = [
"base58",
"bincode",
"bip39",
"bridge_core",
"clap",
"common",
"derive_more",

View File

@ -46,7 +46,7 @@ _wallet() {
cword=$COMP_CWORD
}
local commands="auth-transfer chain-info account pinata token amm ata check-health config restore-keys deploy-program help"
local commands="auth-transfer chain-info account pinata token amm ata 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.
@ -535,6 +535,26 @@ _wallet() {
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
;;
esac
;;
config)
case "$subcmd" in
"")

View File

@ -25,6 +25,7 @@ _wallet() {
'token:Token program interaction subcommand'
'amm:AMM program interaction subcommand'
'ata:Associated Token Account 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'
@ -56,6 +57,9 @@ _wallet() {
ata)
_wallet_ata
;;
bridge)
_wallet_bridge
;;
config)
_wallet_config
;;
@ -442,6 +446,35 @@ _wallet_ata() {
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
@ -515,6 +548,7 @@ _wallet_help() {
'token:Token program interaction subcommand'
'amm:AMM program interaction subcommand'
'ata:Associated Token Account 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

@ -32,6 +32,7 @@ indexer_service_protocol.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
@ -39,3 +40,4 @@ reqwest.workspace = true
borsh.workspace = true
logos-blockchain-http-api-common.workspace = true
logos-blockchain-core.workspace = true
logos-blockchain-zone-sdk.workspace = true

View File

@ -9,7 +9,8 @@ use std::time::Duration;
use anyhow::Context as _;
use borsh::BorshSerialize;
use common::transaction::NSSATransaction;
use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext};
use futures::StreamExt as _;
use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention};
use log::info;
use logos_blockchain_core::mantle::{ledger::Inputs, ops::channel::deposit::DepositOp};
use logos_blockchain_http_api_common::bodies::{
@ -19,6 +20,9 @@ use logos_blockchain_http_api_common::bodies::{
transfer_funds::{WalletTransferFundsRequestBody, WalletTransferFundsResponseBody},
},
};
use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use nssa::{
AccountId, execute_and_prove, privacy_preserving_transaction, program::Program,
public_transaction,
@ -26,6 +30,7 @@ use nssa::{
use nssa_core::{InputAccountIdentity, account::AccountWithMetadata};
use sequencer_service_rpc::RpcClient as _;
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);
@ -194,6 +199,7 @@ 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: u64,
) -> anyhow::Result<()> {
@ -206,15 +212,13 @@ async fn submit_bedrock_deposit(
let metadata = borsh::to_vec(&DepositMetadata { recipient_id })
.context("Failed to encode deposit metadata")?;
let funding_key = "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26";
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
@ -231,13 +235,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
);
@ -363,11 +367,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);
@ -381,10 +392,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
@ -404,7 +422,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")?;
@ -439,9 +459,88 @@ 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"
);
// 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);
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")?;
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,
) -> anyhow::Result<()> {
let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK
+ Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS);
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 amount = withdraw.outputs.amount().context(
"Failed to compute finalized withdraw amount from zone indexer message",
)?;
if amount == expected_amount {
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

@ -2,8 +2,8 @@ use std::{pin::Pin, sync::Arc, time::Duration};
use anyhow::{Context as _, Result};
use common::block::Block;
use log::warn;
use logos_blockchain_core::mantle::{Note, ledger::Outputs};
use log::{info, warn};
use logos_blockchain_core::mantle::{Note, ledger::Outputs, 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::{
@ -135,19 +135,23 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
bridge_withdrawals: Vec<BridgeWithdrawData>,
) -> 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 bridge_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 withdraws = bridge_withdrawals
let withdraws: Vec<_> = bridge_withdrawals
.into_iter()
.map(|withdrawal| {
let recipient_pk =
@ -161,10 +165,16 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
})
.collect();
let withdraw_count = withdraws.len();
self.handle
.publish_atomic_withdraw(data_bounded, withdraws)
.await
.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

@ -14,16 +14,17 @@ 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
bridge_core.workspace = true
keycard_wallet.workspace = true
bip39.workspace = true
pyo3.workspace = true
rpassword = "7"
zeroize = "1"
keycard_wallet.workspace = true
anyhow.workspace = true
thiserror.workspace = true
serde_json.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,
},
@ -65,6 +65,9 @@ pub enum Command {
/// Associated Token Account program interaction subcommand.
#[command(subcommand)]
Ata(AtaSubcommand),
/// Bridge program interaction subcommand.
#[command(subcommand)]
Bridge(BridgeSubcommand),
/// Group key management (create, invite, join, derive keys).
#[command(subcommand)]
Group(GroupSubcommand),
@ -233,6 +236,9 @@ pub async fn execute_subcommand(
Command::Token(token_subcommand) => token_subcommand.handle_subcommand(wallet_core).await?,
Command::AMM(amm_subcommand) => amm_subcommand.handle_subcommand(wallet_core).await?,
Command::Ata(ata_subcommand) => ata_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 nssa::{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 = nssa::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;