diff --git a/Cargo.lock b/Cargo.lock index 2178d13a..b072d916 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 6f30b095..4bc7994a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index 317fd705..e8eab0e7 100644 Binary files a/artifacts/program_methods/amm.bin and b/artifacts/program_methods/amm.bin differ diff --git a/artifacts/program_methods/associated_token_account.bin b/artifacts/program_methods/associated_token_account.bin index dd841336..ed23efb4 100644 Binary files a/artifacts/program_methods/associated_token_account.bin and b/artifacts/program_methods/associated_token_account.bin differ diff --git a/artifacts/program_methods/authenticated_transfer.bin b/artifacts/program_methods/authenticated_transfer.bin index ec15731b..481baa83 100644 Binary files a/artifacts/program_methods/authenticated_transfer.bin and b/artifacts/program_methods/authenticated_transfer.bin differ diff --git a/artifacts/program_methods/bridge.bin b/artifacts/program_methods/bridge.bin index 69c18210..c55b6f94 100644 Binary files a/artifacts/program_methods/bridge.bin and b/artifacts/program_methods/bridge.bin differ diff --git a/artifacts/program_methods/clock.bin b/artifacts/program_methods/clock.bin index ddbfea9b..151a72d7 100644 Binary files a/artifacts/program_methods/clock.bin and b/artifacts/program_methods/clock.bin differ diff --git a/artifacts/program_methods/faucet.bin b/artifacts/program_methods/faucet.bin index 4994d29e..1385c2e1 100644 Binary files a/artifacts/program_methods/faucet.bin and b/artifacts/program_methods/faucet.bin differ diff --git a/artifacts/program_methods/pinata.bin b/artifacts/program_methods/pinata.bin index 84ea7e23..7ae7c005 100644 Binary files a/artifacts/program_methods/pinata.bin and b/artifacts/program_methods/pinata.bin differ diff --git a/artifacts/program_methods/pinata_token.bin b/artifacts/program_methods/pinata_token.bin index cb240b78..a2581702 100644 Binary files a/artifacts/program_methods/pinata_token.bin and b/artifacts/program_methods/pinata_token.bin differ diff --git a/artifacts/program_methods/privacy_preserving_circuit.bin b/artifacts/program_methods/privacy_preserving_circuit.bin index a7f6d3e9..ebd4c38f 100644 Binary files a/artifacts/program_methods/privacy_preserving_circuit.bin and b/artifacts/program_methods/privacy_preserving_circuit.bin differ diff --git a/artifacts/program_methods/token.bin b/artifacts/program_methods/token.bin index 59989832..430122b1 100644 Binary files a/artifacts/program_methods/token.bin and b/artifacts/program_methods/token.bin differ diff --git a/artifacts/program_methods/vault.bin b/artifacts/program_methods/vault.bin index 81e79348..57bba45e 100644 Binary files a/artifacts/program_methods/vault.bin and b/artifacts/program_methods/vault.bin differ diff --git a/artifacts/test_program_methods/auth_asserting_noop.bin b/artifacts/test_program_methods/auth_asserting_noop.bin index 0e9e0dc1..2507eacb 100644 Binary files a/artifacts/test_program_methods/auth_asserting_noop.bin and b/artifacts/test_program_methods/auth_asserting_noop.bin differ diff --git a/artifacts/test_program_methods/auth_transfer_proxy.bin b/artifacts/test_program_methods/auth_transfer_proxy.bin index c5863615..2672cd29 100644 Binary files a/artifacts/test_program_methods/auth_transfer_proxy.bin and b/artifacts/test_program_methods/auth_transfer_proxy.bin differ diff --git a/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index c7684bfc..d0d9893a 100644 Binary files a/artifacts/test_program_methods/burner.bin and b/artifacts/test_program_methods/burner.bin differ diff --git a/artifacts/test_program_methods/chain_caller.bin b/artifacts/test_program_methods/chain_caller.bin index 39b6cb2f..4511b516 100644 Binary files a/artifacts/test_program_methods/chain_caller.bin and b/artifacts/test_program_methods/chain_caller.bin differ diff --git a/artifacts/test_program_methods/changer_claimer.bin b/artifacts/test_program_methods/changer_claimer.bin index d1f2cb8a..c657a86d 100644 Binary files a/artifacts/test_program_methods/changer_claimer.bin and b/artifacts/test_program_methods/changer_claimer.bin differ diff --git a/artifacts/test_program_methods/claimer.bin b/artifacts/test_program_methods/claimer.bin index 035fdb23..47b451ec 100644 Binary files a/artifacts/test_program_methods/claimer.bin and b/artifacts/test_program_methods/claimer.bin differ diff --git a/artifacts/test_program_methods/clock_chain_caller.bin b/artifacts/test_program_methods/clock_chain_caller.bin index b9406338..b6e5b7f6 100644 Binary files a/artifacts/test_program_methods/clock_chain_caller.bin and b/artifacts/test_program_methods/clock_chain_caller.bin differ diff --git a/artifacts/test_program_methods/data_changer.bin b/artifacts/test_program_methods/data_changer.bin index 42429550..4cae9b77 100644 Binary files a/artifacts/test_program_methods/data_changer.bin and b/artifacts/test_program_methods/data_changer.bin differ diff --git a/artifacts/test_program_methods/extra_output.bin b/artifacts/test_program_methods/extra_output.bin index 9685f34f..d93baede 100644 Binary files a/artifacts/test_program_methods/extra_output.bin and b/artifacts/test_program_methods/extra_output.bin differ diff --git a/artifacts/test_program_methods/faucet_chain_caller.bin b/artifacts/test_program_methods/faucet_chain_caller.bin index b72df852..01e32aa1 100644 Binary files a/artifacts/test_program_methods/faucet_chain_caller.bin and b/artifacts/test_program_methods/faucet_chain_caller.bin differ diff --git a/artifacts/test_program_methods/flash_swap_callback.bin b/artifacts/test_program_methods/flash_swap_callback.bin index 2f9020c9..028b64df 100644 Binary files a/artifacts/test_program_methods/flash_swap_callback.bin and b/artifacts/test_program_methods/flash_swap_callback.bin differ diff --git a/artifacts/test_program_methods/flash_swap_initiator.bin b/artifacts/test_program_methods/flash_swap_initiator.bin index 6ef7044b..f18afc5f 100644 Binary files a/artifacts/test_program_methods/flash_swap_initiator.bin and b/artifacts/test_program_methods/flash_swap_initiator.bin differ diff --git a/artifacts/test_program_methods/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index 37716f90..99359e22 100644 Binary files a/artifacts/test_program_methods/malicious_authorization_changer.bin and b/artifacts/test_program_methods/malicious_authorization_changer.bin differ diff --git a/artifacts/test_program_methods/malicious_caller_program_id.bin b/artifacts/test_program_methods/malicious_caller_program_id.bin index 09507957..fe8f5511 100644 Binary files a/artifacts/test_program_methods/malicious_caller_program_id.bin and b/artifacts/test_program_methods/malicious_caller_program_id.bin differ diff --git a/artifacts/test_program_methods/malicious_injector.bin b/artifacts/test_program_methods/malicious_injector.bin index cb90bbc5..9cadd3b3 100644 Binary files a/artifacts/test_program_methods/malicious_injector.bin and b/artifacts/test_program_methods/malicious_injector.bin differ diff --git a/artifacts/test_program_methods/malicious_launderer.bin b/artifacts/test_program_methods/malicious_launderer.bin index 368602ca..9686a8f4 100644 Binary files a/artifacts/test_program_methods/malicious_launderer.bin and b/artifacts/test_program_methods/malicious_launderer.bin differ diff --git a/artifacts/test_program_methods/malicious_self_program_id.bin b/artifacts/test_program_methods/malicious_self_program_id.bin index dd0343d0..853740ba 100644 Binary files a/artifacts/test_program_methods/malicious_self_program_id.bin and b/artifacts/test_program_methods/malicious_self_program_id.bin differ diff --git a/artifacts/test_program_methods/minter.bin b/artifacts/test_program_methods/minter.bin index 7bf2b4ed..a012dd94 100644 Binary files a/artifacts/test_program_methods/minter.bin and b/artifacts/test_program_methods/minter.bin differ diff --git a/artifacts/test_program_methods/missing_output.bin b/artifacts/test_program_methods/missing_output.bin index 6bac61a9..4a056f6d 100644 Binary files a/artifacts/test_program_methods/missing_output.bin and b/artifacts/test_program_methods/missing_output.bin differ diff --git a/artifacts/test_program_methods/modified_transfer.bin b/artifacts/test_program_methods/modified_transfer.bin index 83a6c3b8..65f77b77 100644 Binary files a/artifacts/test_program_methods/modified_transfer.bin and b/artifacts/test_program_methods/modified_transfer.bin differ diff --git a/artifacts/test_program_methods/nonce_changer.bin b/artifacts/test_program_methods/nonce_changer.bin index 45fd8ea8..e1cb3175 100644 Binary files a/artifacts/test_program_methods/nonce_changer.bin and b/artifacts/test_program_methods/nonce_changer.bin differ diff --git a/artifacts/test_program_methods/noop.bin b/artifacts/test_program_methods/noop.bin index 4c8248e0..7575a2c3 100644 Binary files a/artifacts/test_program_methods/noop.bin and b/artifacts/test_program_methods/noop.bin differ diff --git a/artifacts/test_program_methods/pda_claimer.bin b/artifacts/test_program_methods/pda_claimer.bin index 5dc4031e..55a934b1 100644 Binary files a/artifacts/test_program_methods/pda_claimer.bin and b/artifacts/test_program_methods/pda_claimer.bin differ diff --git a/artifacts/test_program_methods/pda_spend_proxy.bin b/artifacts/test_program_methods/pda_spend_proxy.bin index 6b6e6a41..8f7e8977 100644 Binary files a/artifacts/test_program_methods/pda_spend_proxy.bin and b/artifacts/test_program_methods/pda_spend_proxy.bin differ diff --git a/artifacts/test_program_methods/pinata_cooldown.bin b/artifacts/test_program_methods/pinata_cooldown.bin index d807c71c..5655e958 100644 Binary files a/artifacts/test_program_methods/pinata_cooldown.bin and b/artifacts/test_program_methods/pinata_cooldown.bin differ diff --git a/artifacts/test_program_methods/private_pda_delegator.bin b/artifacts/test_program_methods/private_pda_delegator.bin index 5fb95519..163135b6 100644 Binary files a/artifacts/test_program_methods/private_pda_delegator.bin and b/artifacts/test_program_methods/private_pda_delegator.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index 44ab9808..185e521e 100644 Binary files a/artifacts/test_program_methods/program_owner_changer.bin and b/artifacts/test_program_methods/program_owner_changer.bin differ diff --git a/artifacts/test_program_methods/simple_balance_transfer.bin b/artifacts/test_program_methods/simple_balance_transfer.bin index 73f85aef..57510322 100644 Binary files a/artifacts/test_program_methods/simple_balance_transfer.bin and b/artifacts/test_program_methods/simple_balance_transfer.bin differ diff --git a/artifacts/test_program_methods/time_locked_transfer.bin b/artifacts/test_program_methods/time_locked_transfer.bin index 4955beb3..f762398b 100644 Binary files a/artifacts/test_program_methods/time_locked_transfer.bin and b/artifacts/test_program_methods/time_locked_transfer.bin differ diff --git a/artifacts/test_program_methods/two_pda_claimer.bin b/artifacts/test_program_methods/two_pda_claimer.bin index 15c990c2..33995cb7 100644 Binary files a/artifacts/test_program_methods/two_pda_claimer.bin and b/artifacts/test_program_methods/two_pda_claimer.bin differ diff --git a/artifacts/test_program_methods/validity_window.bin b/artifacts/test_program_methods/validity_window.bin index 65aa5988..88cbf9c3 100644 Binary files a/artifacts/test_program_methods/validity_window.bin and b/artifacts/test_program_methods/validity_window.bin differ diff --git a/artifacts/test_program_methods/validity_window_chain_caller.bin b/artifacts/test_program_methods/validity_window_chain_caller.bin index c13a3810..a8cb5663 100644 Binary files a/artifacts/test_program_methods/validity_window_chain_caller.bin and b/artifacts/test_program_methods/validity_window_chain_caller.bin differ diff --git a/completions/bash/wallet b/completions/bash/wallet index d714122c..4820d985 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 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 ;; diff --git a/completions/zsh/_wallet b/completions/zsh/_wallet index ea3de32f..c16b9ada 100644 --- a/completions/zsh/_wallet +++ b/completions/zsh/_wallet @@ -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' diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index b35f9deb..3b1731d8 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -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 diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index e7d52e83..41781a68 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -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> { + 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, + 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}") + })? +} diff --git a/lee/state_machine/core/src/commitment.rs b/lee/state_machine/core/src/commitment.rs index 7c81c12c..92085d7d 100644 --- a/lee/state_machine/core/src/commitment.rs +++ b/lee/state_machine/core/src/commitment.rs @@ -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] = diff --git a/lee/state_machine/core/src/nullifier.rs b/lee/state_machine/core/src/nullifier.rs index d1fbae42..0490ac00 100644 --- a/lee/state_machine/core/src/nullifier.rs +++ b/lee/state_machine/core/src/nullifier.rs @@ -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"; diff --git a/lez/common/src/transaction.rs b/lez/common/src/transaction.rs index a74474a9..7fb32e39 100644 --- a/lez/common/src/transaction.rs +++ b/lez/common/src/transaction.rs @@ -80,15 +80,16 @@ impl LeeTransaction { ) -> Result { 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 for LeeTransaction { diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index f7296f42..8a6a8c08 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -19,6 +19,8 @@ faucet_core.workspace = true bridge_core.workspace = true vault_core.workspace = true +logos-blockchain-key-management-system-service.workspace = true +logos-blockchain-core.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true @@ -27,13 +29,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 diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index f07a47c6..ab56945f 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -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; pub type OnDepositEventSink = Box Pin + Send>> + Send + 'static>; +/// Sink for finalized Bedrock withdraw events. +pub type OnWithdrawEventSink = + Box Pin + 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; /// 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) -> 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 { 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) -> 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(()) } diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index 8a2e9265..4e5489ff 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -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, @@ -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, + 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> { self.dbio.get_pending_deposit_events() } - - pub fn mark_unfulfilled_deposit_events_submitted( - &self, - deposit_op_ids: &[HashType], - submitted_block_id: u64, - ) -> DbResult { - 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 { - self.dbio - .remove_fulfilled_pending_deposit_events_up_to_block(finalized_block_id) - } } pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap { @@ -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(); } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 2a8af528..722a081b 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -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 SequencerCore { .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) -> 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 SequencerCore { 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) -> 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 SequencerCore { ); } } - }); + }) + } - 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, + 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 SequencerCore { } }; - if let Err(err) = mempool_handle_for_deposit + if let Err(err) = mempool_handle .push((TransactionOrigin::Sequencer, tx)) .await { @@ -222,69 +267,66 @@ impl SequencerCore { ); } }) - }); + }) + } - 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) -> 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 { - 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::>() + .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 SequencerCore { 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 SequencerCore { } }; + 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 SequencerCore { 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 SequencerCore { } valid_transactions.push(tx); + info!("Validated transaction with hash {tx_hash}, including it in block"); if valid_transactions.len() >= self.sequencer_config.max_num_tx_in_block { break; @@ -427,6 +466,7 @@ impl SequencerCore { Ok(BlockWithMeta { block, deposit_event_ids, + withdrawals, }) } @@ -486,6 +526,7 @@ impl SequencerCore { struct BlockWithMeta { block: Block, deposit_event_ids: Vec, + withdrawals: Vec, } /// 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 { + 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::(&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 { + 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::(&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 { + 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 { 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" + ); + } } diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index e041269a..5f2ab8cf 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -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 { Ok(Self) } - async fn publish_block(&self, _block: &Block) -> Result<()> { + async fn publish_block( + &self, + _block: &Block, + _bridge_withdrawals: Vec, + ) -> Result<()> { Ok(()) } } diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index 803e6d60..44068517 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -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 { 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 { 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 { + let current = self + .get_opt::(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 { + let Some(current) = self + .get_opt::(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 = + ::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, + 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, diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 39b6a406..7672e271 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -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> { + 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> { + 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::{ diff --git a/lez/wallet/Cargo.toml b/lez/wallet/Cargo.toml index b6b8bdbd..e04b0d91 100644 --- a/lez/wallet/Cargo.toml +++ b/lez/wallet/Cargo.toml @@ -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 diff --git a/lez/wallet/src/cli/mod.rs b/lez/wallet/src/cli/mod.rs index c30b8a56..8a91d3ae 100644 --- a/lez/wallet/src/cli/mod.rs +++ b/lez/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, 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? diff --git a/lez/wallet/src/cli/programs/bridge.rs b/lez/wallet/src/cli/programs/bridge.rs new file mode 100644 index 00000000..8d8cf5ee --- /dev/null +++ b/lez/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/lez/wallet/src/cli/programs/mod.rs b/lez/wallet/src/cli/programs/mod.rs index 32133606..bc8abaf2 100644 --- a/lez/wallet/src/cli/programs/mod.rs +++ b/lez/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/lez/wallet/src/program_facades/bridge.rs b/lez/wallet/src/program_facades/bridge.rs new file mode 100644 index 00000000..60ad9483 --- /dev/null +++ b/lez/wallet/src/program_facades/bridge.rs @@ -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 { + 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 + } +} diff --git a/lez/wallet/src/program_facades/mod.rs b/lez/wallet/src/program_facades/mod.rs index cfeb54ad..a634e4ca 100644 --- a/lez/wallet/src/program_facades/mod.rs +++ b/lez/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; diff --git a/program_methods/guest/src/bin/bridge.rs b/program_methods/guest/src/bin/bridge.rs index eb082c7c..19d6509c 100644 --- a/program_methods/guest/src/bin/bridge.rs +++ b/program_methods/guest/src/bin/bridge.rs @@ -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( diff --git a/programs/bridge/core/src/lib.rs b/programs/bridge/core/src/lib.rs index 1e1bff4f..c9666f27 100644 --- a/programs/bridge/core/src/lib.rs +++ b/programs/bridge/core/src/lib.rs @@ -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], }, }