diff --git a/Cargo.lock b/Cargo.lock index 37cbd0a5..3c4b9391 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/completions/bash/wallet b/completions/bash/wallet index a4d390f6..6e07c1e7 100644 --- a/completions/bash/wallet +++ b/completions/bash/wallet @@ -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 "") diff --git a/completions/zsh/_wallet b/completions/zsh/_wallet index 8f573ab0..5525b25f 100644 --- a/completions/zsh/_wallet +++ b/completions/zsh/_wallet @@ -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' diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 0a0048bd..7767d120 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -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 diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index cfd361d3..c108a66f 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -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> { + 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, + 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}") + })? +} diff --git a/sequencer/core/src/block_publisher.rs b/sequencer/core/src/block_publisher.rs index b0aa7b0e..3b5c1d4a 100644 --- a/sequencer/core/src/block_publisher.rs +++ b/sequencer/core/src/block_publisher.rs @@ -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, ) -> 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(()) } } diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 3aaa1753..93178d7c 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -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 diff --git a/wallet/src/cli/mod.rs b/wallet/src/cli/mod.rs index 8acdfa67..9ae71a0d 100644 --- a/wallet/src/cli/mod.rs +++ b/wallet/src/cli/mod.rs @@ -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? diff --git a/wallet/src/cli/programs/bridge.rs b/wallet/src/cli/programs/bridge.rs new file mode 100644 index 00000000..8d8cf5ee --- /dev/null +++ b/wallet/src/cli/programs/bridge.rs @@ -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 { + 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) +} diff --git a/wallet/src/cli/programs/mod.rs b/wallet/src/cli/programs/mod.rs index f6e4b5dc..32cb7ff4 100644 --- a/wallet/src/cli/programs/mod.rs +++ b/wallet/src/cli/programs/mod.rs @@ -1,5 +1,6 @@ pub mod amm; pub mod ata; +pub mod bridge; pub mod native_token_transfer; pub mod pinata; pub mod token; diff --git a/wallet/src/program_facades/bridge.rs b/wallet/src/program_facades/bridge.rs new file mode 100644 index 00000000..2baab53d --- /dev/null +++ b/wallet/src/program_facades/bridge.rs @@ -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 { + 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 + } +} diff --git a/wallet/src/program_facades/mod.rs b/wallet/src/program_facades/mod.rs index a0f8189c..8b62094d 100644 --- a/wallet/src/program_facades/mod.rs +++ b/wallet/src/program_facades/mod.rs @@ -3,6 +3,7 @@ pub mod amm; pub mod ata; +pub mod bridge; pub mod native_token_transfer; pub mod pinata; pub mod token;