diff --git a/Cargo.lock b/Cargo.lock index 3d7d2921..58f5ad2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1319,6 +1319,14 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bridge_core" +version = "0.1.0" +dependencies = [ + "nssa_core", + "serde", +] + [[package]] name = "bs58" version = "0.5.1" @@ -2847,6 +2855,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2854,7 +2871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2868,6 +2885,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -3638,6 +3661,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -3656,9 +3695,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.3", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -4071,6 +4112,8 @@ dependencies = [ "anyhow", "ata_core", "authenticated_transfer_core", + "borsh", + "bridge_core", "bytesize", "common", "faucet_core", @@ -4080,8 +4123,11 @@ dependencies = [ "indexer_service_rpc", "key_protocol", "log", + "logos-blockchain-core", + "logos-blockchain-http-api-common", "nssa", "nssa_core", + "reqwest", "sequencer_core", "sequencer_service_rpc", "serde_json", @@ -6114,7 +6160,7 @@ dependencies = [ "bitflags 2.11.0", "block", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -6253,6 +6299,23 @@ dependencies = [ "unsigned-varint 0.7.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "natpmp" version = "0.5.0" @@ -6460,6 +6523,7 @@ dependencies = [ "anyhow", "authenticated_transfer_core", "borsh", + "bridge_core", "clock_core", "env_logger", "faucet_core", @@ -6718,12 +6782,49 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.31.0" @@ -7241,6 +7342,7 @@ dependencies = [ "ata_core", "ata_program", "authenticated_transfer_core", + "bridge_core", "clock_core", "faucet_core", "nssa_core", @@ -7820,6 +7922,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", "h2", @@ -7828,9 +7931,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -7841,6 +7947,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -8717,6 +8824,7 @@ version = "0.1.0" dependencies = [ "anyhow", "borsh", + "bridge_core", "bytesize", "clap", "common", @@ -8724,6 +8832,8 @@ dependencies = [ "futures", "jsonrpsee", "log", + "logos-blockchain-core", + "logos-blockchain-zone-sdk", "mempool", "nssa", "sequencer_core", @@ -8731,6 +8841,7 @@ dependencies = [ "sequencer_service_rpc", "tokio", "tokio-util", + "vault_core", ] [[package]] @@ -9700,6 +9811,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/Cargo.toml b/Cargo.toml index f4a981ad..19f4c066 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "programs/associated_token_account", "programs/authenticated_transfer/core", "programs/faucet/core", + "programs/bridge/core", "programs/vault/core", "sequencer/core", "sequencer/service", @@ -75,6 +76,7 @@ ata_core = { path = "programs/associated_token_account/core" } ata_program = { path = "programs/associated_token_account" } authenticated_transfer_core = { path = "programs/authenticated_transfer/core" } faucet_core = { path = "programs/faucet/core" } +bridge_core = { path = "programs/bridge/core" } vault_core = { path = "programs/vault/core" } test_program_methods = { path = "test_program_methods" } testnet_initial_state = { path = "testnet_initial_state" } @@ -141,6 +143,7 @@ logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-block logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } +logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } rocksdb = { version = "0.24.0", default-features = false, features = [ "snappy", diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index 3fbea6d0..7c253deb 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 e6cdba59..b5d792ec 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 0cdaf90d..f25dfbed 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 58a6cf32..4c30a084 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 06b983ce..11770520 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 c4a82241..0b71bc7a 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 d8634938..0ca23903 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 adfd3cb6..3db085a1 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 5aaae0ea..bde4df02 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 f11ca891..057671ff 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 4bf1bfdf..cacb5988 100644 Binary files a/artifacts/program_methods/vault.bin and b/artifacts/program_methods/vault.bin differ diff --git a/artifacts/test_program_methods/faucet_chain_caller.bin b/artifacts/test_program_methods/faucet_chain_caller.bin index beeae731..49a6d3a6 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/common/src/transaction.rs b/common/src/transaction.rs index 21cbfd75..0015e9a9 100644 --- a/common/src/transaction.rs +++ b/common/src/transaction.rs @@ -67,7 +67,7 @@ impl NSSATransaction { } /// Validates the transaction against the current state and returns the resulting diff - /// without applying it. Rejects transactions that modify clock or faucet system accounts, + /// without applying it. Rejects transactions that modify clock, faucet or bridge accounts, /// whether directly or indirectly via chain calls. /// /// This check is required for all user transactions. Only sequencer transactions may bypass @@ -90,26 +90,12 @@ impl NSSATransaction { } }?; - let public_diff = diff.public_diff(); - let touches_clock = nssa::CLOCK_PROGRAM_ACCOUNT_IDS.iter().any(|id| { - public_diff - .get(id) - .is_some_and(|post| *post != state.get_account_by_id(*id)) - }); - if touches_clock { - return Err(nssa::error::NssaError::InvalidInput( - "Transaction modifies system clock accounts".into(), - )); - } - - let faucet_id = nssa::system_faucet_account_id(); - if public_diff - .get(&faucet_id) - .is_some_and(|post| *post != state.get_account_by_id(faucet_id)) - { - return Err(nssa::error::NssaError::InvalidInput( - "Transaction modifies system faucet account".into(), - )); + let system_accounts = nssa::CLOCK_PROGRAM_ACCOUNT_IDS.iter().copied().chain([ + nssa::system_faucet_account_id(), + nssa::system_bridge_account_id(), + ]); + for account_id in system_accounts { + validate_doesnt_modify_account(state, &diff, account_id)?; } Ok(diff) @@ -184,3 +170,21 @@ pub fn clock_invocation(timestamp: clock_core::Instruction) -> nssa::PublicTrans nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), ) } + +fn validate_doesnt_modify_account( + state: &V03State, + diff: &ValidatedStateDiff, + account_id: AccountId, +) -> Result<(), nssa::error::NssaError> { + if diff + .public_diff() + .get(&account_id) + .is_some_and(|post| *post != state.get_account_by_id(account_id)) + { + Err(nssa::error::NssaError::InvalidInput(format!( + "Transaction modifies restricted system account {account_id}" + ))) + } else { + Ok(()) + } +} diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 82d8ebd1..0a0048bd 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -22,6 +22,7 @@ token_core.workspace = true ata_core.workspace = true vault_core.workspace = true faucet_core.workspace = true +bridge_core.workspace = true indexer_service_rpc = { workspace = true, features = ["client"] } sequencer_service_rpc = { workspace = true, features = ["client"] } wallet-ffi.workspace = true @@ -34,3 +35,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } 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 diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index 72685d0b..ab325669 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -420,7 +420,7 @@ async fn cannot_execute_faucet_program() -> Result<()> { Program::faucet().id(), vec![faucet_account_id, recipient_vault_id], vec![], - faucet_core::Instruction::Transfer { + faucet_core::Instruction::TransferVault { vault_program_id, recipient_id: recipient, amount, diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs new file mode 100644 index 00000000..cd8c8fa5 --- /dev/null +++ b/integration_tests/tests/bridge.rs @@ -0,0 +1,470 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "We don't care about these in tests" +)] + +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 log::info; +use logos_blockchain_core::mantle::{Value, ledger::Inputs, ops::channel::deposit::DepositOp}; +use logos_blockchain_http_api_common::bodies::{ + channel::ChannelDepositRequestBody, + wallet::{ + balance::WalletBalanceResponseBody, + transfer_funds::{WalletTransferFundsRequestBody, WalletTransferFundsResponseBody}, + }, +}; +use nssa::{ + AccountId, execute_and_prove, privacy_preserving_transaction, program::Program, + public_transaction, +}; +use nssa_core::{InputAccountIdentity, account::AccountWithMetadata}; +use sequencer_service_rpc::RpcClient as _; +use tokio::test; + +#[test] +async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { + let ctx = TestContext::new().await?; + + let recipient_id = ctx.existing_public_accounts()[0]; + let bridge_account_id = nssa::system_bridge_account_id(); + let vault_program_id = Program::vault().id(); + let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id); + + let message = public_transaction::Message::try_new( + Program::bridge().id(), + vec![bridge_account_id, recipient_vault_id], + vec![], + bridge_core::Instruction::Deposit { + vault_program_id, + recipient_id, + amount: 1, + }, + ) + .context("Failed to build public bridge deposit transaction")?; + + let attack_tx = NSSATransaction::Public(nssa::PublicTransaction::new( + message, + nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), + )); + + let bridge_balance_before = ctx + .sequencer_client() + .get_account_balance(bridge_account_id) + .await?; + let vault_balance_before = ctx + .sequencer_client() + .get_account_balance(recipient_vault_id) + .await?; + + let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?; + + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + let bridge_balance_after = ctx + .sequencer_client() + .get_account_balance(bridge_account_id) + .await?; + let vault_balance_after = ctx + .sequencer_client() + .get_account_balance(recipient_vault_id) + .await?; + let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; + + assert_eq!(bridge_balance_after, bridge_balance_before); + assert_eq!(vault_balance_after, vault_balance_before); + assert!( + tx_on_chain.is_none(), + "Direct public bridge::Deposit invocation should be rejected" + ); + + Ok(()) +} + +#[test] +async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { + let ctx = TestContext::new().await?; + + let recipient_id = ctx.existing_public_accounts()[0]; + let bridge_account_id = nssa::system_bridge_account_id(); + let vault_program_id = Program::vault().id(); + let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id); + + // Get pre-state of bridge and vault accounts + let bridge_pre = AccountWithMetadata::new( + ctx.sequencer_client() + .get_account(bridge_account_id) + .await?, + false, + bridge_account_id, + ); + let vault_pre = AccountWithMetadata::new( + ctx.sequencer_client() + .get_account(recipient_vault_id) + .await?, + false, + recipient_vault_id, + ); + + // Create program with dependencies + let program_with_deps = + nssa::privacy_preserving_transaction::circuit::ProgramWithDependencies::new( + Program::bridge(), + [ + (vault_program_id, Program::vault()), + ( + Program::authenticated_transfer_program().id(), + Program::authenticated_transfer_program(), + ), + ] + .into(), + ); + + // Serialize the bridge deposit instruction + let instruction = Program::serialize_instruction(bridge_core::Instruction::Deposit { + vault_program_id, + recipient_id, + amount: 1, + }) + .context("Failed to serialize bridge deposit instruction")?; + + // Execute and prove the bridge deposit + let (output, proof) = execute_and_prove( + vec![bridge_pre.clone(), vault_pre.clone()], + instruction, + vec![InputAccountIdentity::Public, InputAccountIdentity::Public], + &program_with_deps, + ) + .context("Failed to execute/prove bridge deposit")?; + + // Create privacy-preserving transaction from circuit output + let message = privacy_preserving_transaction::Message::try_from_circuit_output( + vec![bridge_account_id, recipient_vault_id], + vec![bridge_pre.account.nonce, vault_pre.account.nonce], + vec![], + output, + ) + .context("Failed to build privacy-preserving bridge deposit message")?; + + let witness_set = privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]); + let attack_tx = NSSATransaction::PrivacyPreserving(nssa::PrivacyPreservingTransaction::new( + message, + witness_set, + )); + + let bridge_balance_before = ctx + .sequencer_client() + .get_account_balance(bridge_account_id) + .await?; + let vault_balance_before = ctx + .sequencer_client() + .get_account_balance(recipient_vault_id) + .await?; + + let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?; + + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + let bridge_balance_after = ctx + .sequencer_client() + .get_account_balance(bridge_account_id) + .await?; + let vault_balance_after = ctx + .sequencer_client() + .get_account_balance(recipient_vault_id) + .await?; + let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; + + assert_eq!(bridge_balance_after, bridge_balance_before); + assert_eq!(vault_balance_after, vault_balance_before); + assert!( + tx_on_chain.is_none(), + "Privacy-preserving bridge::Deposit invocation should be rejected" + ); + + Ok(()) +} + +#[derive(BorshSerialize)] +struct DepositMetadata { + recipient_id: AccountId, +} + +async fn submit_bedrock_deposit( + bedrock_addr: std::net::SocketAddr, + recipient_id: AccountId, + amount: u128, +) -> anyhow::Result<()> { + // Encode deposit metadata + let metadata = borsh::to_vec(&DepositMetadata { recipient_id }) + .context("Failed to encode deposit metadata")?; + + 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" + )) + .send() + .await + .context("Failed to query Bedrock wallet balance")?; + + if !balance_response.status().is_success() { + let status = balance_response.status(); + let body_text = balance_response.text().await.unwrap_or_default(); + anyhow::bail!( + "Bedrock balance query failed with status {} and body {}", + status, + body_text, + ); + } + + balance_response + .json::() + .await + .context("Failed to decode Bedrock balance response") + }; + + let mut balance = query_balance().await?; + + info!( + "Queried Bedrock balance for key {funding_key}: {:?}", + balance.balance + ); + + if balance.balance < amount { + anyhow::bail!( + "Bedrock wallet with key {funding_key} has insufficient balance {:?} for deposit amount {:?}", + balance.balance, + amount + ); + } + + let mut selected_note_id = balance + .notes + .iter() + .find_map(|(note_id, value)| (*value == amount).then_some(*note_id)); + + if selected_note_id.is_none() { + let transfer_body = WalletTransferFundsRequestBody { + tip: None, + change_public_key: balance.address, + funding_public_keys: vec![balance.address], + recipient_public_key: balance.address, + amount, + }; + + let transfer_response = client + .post(format!( + "http://{bedrock_addr}/wallet/transactions/transfer-funds" + )) + .json(&transfer_body) + .send() + .await + .context("Failed to submit Bedrock transfer-funds request")?; + + if !transfer_response.status().is_success() { + let status = transfer_response.status(); + let body_text = transfer_response.text().await.unwrap_or_default(); + anyhow::bail!( + "Bedrock transfer-funds request failed with status {} and body {}", + status, + body_text, + ); + } + + let transfer: WalletTransferFundsResponseBody = transfer_response + .json() + .await + .context("Failed to decode Bedrock transfer-funds response")?; + + info!( + "Submitted transfer-funds to create exact deposit note, tx hash {:?}", + transfer.hash + ); + + let mut found_note = None; + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(500)).await; + balance = query_balance().await?; + found_note = balance + .notes + .iter() + .find_map(|(note_id, value)| (*value == amount).then_some(*note_id)); + if found_note.is_some() { + break; + } + } + + selected_note_id = found_note; + } + + let Some(selected_note_id) = selected_note_id else { + anyhow::bail!( + "Failed to locate exact-value note {:?} for Bedrock deposit; available notes: {:?}", + amount, + balance.notes, + ); + }; + + let body = ChannelDepositRequestBody { + tip: None, + deposit: DepositOp { + channel_id, + inputs: Inputs::new(vec![selected_note_id]), + metadata, + }, + change_public_key: balance.address, + funding_public_keys: vec![balance.address], + max_tx_fee: 1_000_u64.into(), + }; + + let response = client + .post(format!("http://{bedrock_addr}/channel/deposit")) + .json(&body) + .send() + .await + .context("Failed to submit Bedrock deposit request")?; + + if response.status().is_success() { + let body_text = response + .text() + .await + .unwrap_or_else(|_| "".to_owned()); + info!( + "Successfully submitted Bedrock deposit request for recipient {recipient_id} and amount {amount}, response body: {}", + body_text + ); + + return Ok(()); + } else { + let status = response.status(); + let body_text = response.text().await.unwrap_or_default(); + anyhow::bail!( + "Bedrock deposit request failed with status {} and body {}", + status, + body_text, + ); + } +} + +async fn wait_for_vault_balance( + ctx: &TestContext, + vault_id: AccountId, + expected_balance: u128, + timeout: Duration, +) -> anyhow::Result<()> { + tokio::time::timeout(timeout, async { + loop { + let balance = ctx.sequencer_client().get_account_balance(vault_id).await?; + if balance == expected_balance { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await + .with_context(|| { + format!("Timed out waiting for vault {vault_id} balance to reach {expected_balance}") + })? +} + +#[test] +async fn bedrock_deposit_mints_to_vault_then_claim_succeeds() -> anyhow::Result<()> { + let ctx = TestContext::new().await?; + + let recipient_id = ctx.existing_public_accounts()[0]; + let vault_program_id = Program::vault().id(); + let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id); + + let vault_balance_before = ctx + .sequencer_client() + .get_account_balance(recipient_vault_id) + .await?; + let recipient_balance_before = ctx + .sequencer_client() + .get_account_balance(recipient_id) + .await?; + + // Submit deposit to Bedrock + submit_bedrock_deposit(ctx.bedrock_addr(), recipient_id, 1).await?; + + // Wait for vault to receive the deposit (minted from bridge to vault) + wait_for_vault_balance( + &ctx, + recipient_vault_id, + vault_balance_before + 1, + Duration::from_mins(5), + ) + .await?; + + // Now claim funds from vault back to recipient + let nonces = ctx + .wallet() + .get_accounts_nonces(vec![recipient_id]) + .await + .context("Failed to get nonce for vault claim")?; + + let signing_key = ctx + .wallet() + .storage() + .key_chain() + .pub_account_signing_key(recipient_id) + .with_context(|| format!("Missing signing key for account {recipient_id}"))?; + + let claim_message = public_transaction::Message::try_new( + vault_program_id, + vec![recipient_id, recipient_vault_id], + nonces, + vault_core::Instruction::Claim { amount: 1 }, + ) + .context("Failed to build vault claim message")?; + + let claim_witness_set = + public_transaction::WitnessSet::for_message(&claim_message, &[signing_key]); + let claim_tx = NSSATransaction::Public(nssa::PublicTransaction::new( + claim_message, + claim_witness_set, + )); + + let claim_hash = ctx.sequencer_client().send_transaction(claim_tx).await?; + + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + let claim_on_chain = ctx.sequencer_client().get_transaction(claim_hash).await?; + let vault_balance_after_claim = ctx + .sequencer_client() + .get_account_balance(recipient_vault_id) + .await?; + let recipient_balance_after_claim = ctx + .sequencer_client() + .get_account_balance(recipient_id) + .await?; + + assert!( + claim_on_chain.is_some(), + "Vault claim transaction must be included on-chain" + ); + assert_eq!( + vault_balance_after_claim, vault_balance_before, + "Vault balance should return to initial state after claim" + ); + assert_eq!( + recipient_balance_after_claim, + recipient_balance_before + 1, + "Recipient balance should increase by claimed amount" + ); + + Ok(()) +} diff --git a/mempool/src/lib.rs b/mempool/src/lib.rs index 3bf4ac2a..1b36eaf7 100644 --- a/mempool/src/lib.rs +++ b/mempool/src/lib.rs @@ -48,6 +48,14 @@ pub struct MemPoolHandle { sender: Sender, } +impl Clone for MemPoolHandle { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + } + } +} + impl MemPoolHandle { const fn new(sender: Sender) -> Self { Self { sender } diff --git a/nssa/Cargo.toml b/nssa/Cargo.toml index 80542f16..ee9b8bb6 100644 --- a/nssa/Cargo.toml +++ b/nssa/Cargo.toml @@ -11,6 +11,7 @@ workspace = true nssa_core = { workspace = true, features = ["host"] } clock_core.workspace = true faucet_core.workspace = true +bridge_core.workspace = true anyhow.workspace = true thiserror.workspace = true diff --git a/nssa/src/lib.rs b/nssa/src/lib.rs index 5998e803..125bf7ee 100644 --- a/nssa/src/lib.rs +++ b/nssa/src/lib.rs @@ -18,7 +18,7 @@ pub use public_transaction::PublicTransaction; pub use signature::{PrivateKey, PublicKey, Signature}; pub use state::{ CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID, - CLOCK_PROGRAM_ACCOUNT_IDS, V03State, system_faucet_account_id, + CLOCK_PROGRAM_ACCOUNT_IDS, V03State, system_bridge_account_id, system_faucet_account_id, }; pub use validated_state_diff::ValidatedStateDiff; diff --git a/nssa/src/program.rs b/nssa/src/program.rs index c3c92f1e..7d9bc779 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -10,8 +10,9 @@ use crate::{ error::NssaError, program_methods::{ AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID, - AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, FAUCET_ELF, - FAUCET_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID, + AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, CLOCK_ELF, + CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, + VAULT_ID, }, }; @@ -164,6 +165,14 @@ impl Program { elf: FAUCET_ELF.to_vec(), } } + + #[must_use] + pub fn bridge() -> Self { + Self { + id: BRIDGE_ID, + elf: BRIDGE_ELF.to_vec(), + } + } } // TODO: Testnet only. Refactor to prevent compilation on mainnet. @@ -194,9 +203,9 @@ mod tests { program::Program, program_methods::{ AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID, - AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, FAUCET_ELF, - FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, TOKEN_ELF, - TOKEN_ID, VAULT_ELF, VAULT_ID, + AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, + CLOCK_ELF, CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, + PINATA_TOKEN_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID, }, }; @@ -529,6 +538,7 @@ mod tests { let token_program = Program::token(); let vault_program = Program::vault(); let faucet_program = Program::faucet(); + let bridge_program = Program::bridge(); let pinata_program = Program::pinata(); assert_eq!(auth_transfer_program.id, AUTHENTICATED_TRANSFER_ID); @@ -539,6 +549,8 @@ mod tests { assert_eq!(vault_program.elf, VAULT_ELF); assert_eq!(faucet_program.id, FAUCET_ID); assert_eq!(faucet_program.elf, FAUCET_ELF); + assert_eq!(bridge_program.id, BRIDGE_ID); + assert_eq!(bridge_program.elf, BRIDGE_ELF); assert_eq!(pinata_program.id, PINATA_ID); assert_eq!(pinata_program.elf, PINATA_ELF); } @@ -551,6 +563,7 @@ mod tests { (ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID), (CLOCK_ELF, CLOCK_ID), (FAUCET_ELF, FAUCET_ID), + (BRIDGE_ELF, BRIDGE_ID), (PINATA_ELF, PINATA_ID), (PINATA_TOKEN_ELF, PINATA_TOKEN_ID), (TOKEN_ELF, TOKEN_ID), diff --git a/nssa/src/state.rs b/nssa/src/state.rs index e9f2058f..e69292a4 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -126,8 +126,11 @@ impl Default for V03State { fn default() -> Self { let faucet_account_id = system_faucet_account_id(); let faucet_account = system_faucet_account(); + let bridge_account_id = system_bridge_account_id(); + let bridge_account = system_bridge_account(); let mut public_state = HashMap::new(); public_state.insert(faucet_account_id, faucet_account); + public_state.insert(bridge_account_id, bridge_account); Self { public_state, @@ -150,6 +153,7 @@ impl V03State { genesis_timestamp: nssa_core::Timestamp, ) -> Self { let faucet_account_id = system_faucet_account_id(); + let bridge_account_id = system_bridge_account_id(); let authenticated_transfer_program = Program::authenticated_transfer_program(); let mut public_state: HashMap<_, _> = initial_data .iter() @@ -164,7 +168,9 @@ impl V03State { }) .collect(); let faucet_account = system_faucet_account(); + let bridge_account = system_bridge_account(); public_state.insert(faucet_account_id, faucet_account); + public_state.insert(bridge_account_id, bridge_account); let mut commitment_set = CommitmentSet::with_capacity(32); commitment_set.extend(&[DUMMY_COMMITMENT]); @@ -190,6 +196,7 @@ impl V03State { this.insert_program(Program::ata()); this.insert_program(Program::vault()); this.insert_program(Program::faucet()); + this.insert_program(Program::bridge()); this } @@ -384,11 +391,23 @@ fn system_faucet_account() -> Account { } } +fn system_bridge_account() -> Account { + Account { + program_owner: Program::authenticated_transfer_program().id(), + ..Account::default() + } +} + #[must_use] pub fn system_faucet_account_id() -> AccountId { faucet_core::compute_faucet_account_id(Program::faucet().id()) } +#[must_use] +pub fn system_bridge_account_id() -> AccountId { + bridge_core::compute_bridge_account_id(Program::bridge().id()) +} + #[cfg(test)] pub mod tests { #![expect( @@ -426,9 +445,10 @@ pub mod tests { signature::PrivateKey, state::{ CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID, - CLOCK_PROGRAM_ACCOUNT_IDS, MAX_NUMBER_CHAINED_CALLS, system_faucet_account, + CLOCK_PROGRAM_ACCOUNT_IDS, MAX_NUMBER_CHAINED_CALLS, system_bridge_account, + system_faucet_account, }, - system_faucet_account_id, + system_bridge_account_id, system_faucet_account_id, }; impl V03State { @@ -622,6 +642,7 @@ pub mod tests { }, ); this.insert(system_faucet_account_id(), system_faucet_account()); + this.insert(system_bridge_account_id(), system_bridge_account()); for account_id in CLOCK_PROGRAM_ACCOUNT_IDS { this.insert( account_id, @@ -646,6 +667,7 @@ pub mod tests { this.insert(Program::ata().id(), Program::ata()); this.insert(Program::vault().id(), Program::vault()); this.insert(Program::faucet().id(), Program::faucet()); + this.insert(Program::bridge().id(), Program::bridge()); this }; diff --git a/program_methods/guest/Cargo.toml b/program_methods/guest/Cargo.toml index 136fb0b8..e60fcc60 100644 --- a/program_methods/guest/Cargo.toml +++ b/program_methods/guest/Cargo.toml @@ -18,6 +18,7 @@ amm_program.workspace = true ata_core.workspace = true ata_program.workspace = true faucet_core.workspace = true +bridge_core.workspace = true vault_core.workspace = true risc0-zkvm.workspace = true serde = { workspace = true, default-features = false } diff --git a/program_methods/guest/src/bin/bridge.rs b/program_methods/guest/src/bin/bridge.rs new file mode 100644 index 00000000..0833d5aa --- /dev/null +++ b/program_methods/guest/src/bin/bridge.rs @@ -0,0 +1,82 @@ +use bridge_core::Instruction; +use nssa_core::program::{ + AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +fn unchanged_post_states( + pre_states: &[nssa_core::account::AccountWithMetadata], +) -> Vec { + pre_states + .iter() + .map(|pre_state| AccountPostState::new(pre_state.account.clone())) + .collect() +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_nssa_inputs::(); + + assert!( + caller_program_id.is_none(), + "Bridge cannot be invoked through chain calls" + ); + + let pre_states_clone = pre_states.clone(); + let post_states = unchanged_post_states(&pre_states_clone); + + let chained_calls = match instruction { + Instruction::Deposit { + vault_program_id, + recipient_id, + amount, + } => { + let [bridge, recipient_vault] = pre_states + .try_into() + .expect("Deposit requires exactly 2 accounts"); + + assert_eq!( + bridge.account_id, + bridge_core::compute_bridge_account_id(self_program_id), + "First account must be bridge PDA" + ); + + assert_eq!( + recipient_vault.account_id, + vault_core::compute_vault_account_id(vault_program_id, recipient_id), + "Second account must be recipient vault PDA" + ); + + let mut bridge_for_vault = bridge; + bridge_for_vault.is_authorized = true; + + vec![ + ChainedCall::new( + vault_program_id, + vec![bridge_for_vault, recipient_vault], + &vault_core::Instruction::Transfer { + recipient_id, + amount, + }, + ) + .with_pda_seeds(vec![bridge_core::compute_bridge_seed()]), + ] + } + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states_clone, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); +} diff --git a/program_methods/guest/src/bin/faucet.rs b/program_methods/guest/src/bin/faucet.rs index e56330cd..6e4068db 100644 --- a/program_methods/guest/src/bin/faucet.rs +++ b/program_methods/guest/src/bin/faucet.rs @@ -23,11 +23,16 @@ fn main() { instruction_words, ) = read_nssa_inputs::(); + assert!( + caller_program_id.is_none(), + "Faucet cannot be invoked through chain calls" + ); + let pre_states_clone = pre_states.clone(); let post_states = unchanged_post_states(&pre_states_clone); let chained_calls = match instruction { - Instruction::Transfer { + Instruction::TransferVault { vault_program_id, recipient_id, amount, @@ -57,6 +62,29 @@ fn main() { .with_pda_seeds(vec![faucet_core::compute_faucet_seed()]), ] } + Instruction::TransferDirect { amount } => { + let [faucet, recipient] = pre_states + .try_into() + .expect("TransferDirect requires exactly 2 accounts"); + + assert_eq!( + faucet.account_id, + faucet_core::compute_faucet_account_id(self_program_id), + "First account must be faucet PDA" + ); + + let mut faucet_for_transfer = faucet; + faucet_for_transfer.is_authorized = true; + + vec![ + ChainedCall::new( + faucet_for_transfer.account.program_owner, + vec![faucet_for_transfer, recipient], + &authenticated_transfer_core::Instruction::Transfer { amount }, + ) + .with_pda_seeds(vec![faucet_core::compute_faucet_seed()]), + ] + } }; ProgramOutput::new( diff --git a/programs/bridge/core/Cargo.toml b/programs/bridge/core/Cargo.toml new file mode 100644 index 00000000..683f2115 --- /dev/null +++ b/programs/bridge/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bridge_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +nssa_core.workspace = true +serde = { workspace = true, default-features = false } diff --git a/programs/bridge/core/src/lib.rs b/programs/bridge/core/src/lib.rs new file mode 100644 index 00000000..1da75f97 --- /dev/null +++ b/programs/bridge/core/src/lib.rs @@ -0,0 +1,29 @@ +pub use nssa_core::program::PdaSeed; +use nssa_core::{account::AccountId, program::ProgramId}; +use serde::{Deserialize, Serialize}; + +const BRIDGE_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/BridgeSeed/0000000000/"; + +#[derive(Serialize, Deserialize)] +pub enum Instruction { + /// Transfers native tokens from the bridge PDA account to a recipient vault. + /// + /// Required accounts (2): + /// - Bridge PDA account + /// - Recipient vault PDA account + Deposit { + vault_program_id: ProgramId, + recipient_id: AccountId, + amount: u128, + }, +} + +#[must_use] +pub const fn compute_bridge_seed() -> PdaSeed { + PdaSeed::new(BRIDGE_SEED_DOMAIN_SEPARATOR) +} + +#[must_use] +pub fn compute_bridge_account_id(bridge_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&bridge_program_id, &compute_bridge_seed()) +} diff --git a/programs/faucet/core/src/lib.rs b/programs/faucet/core/src/lib.rs index da9861e6..2c358c49 100644 --- a/programs/faucet/core/src/lib.rs +++ b/programs/faucet/core/src/lib.rs @@ -11,11 +11,18 @@ pub enum Instruction { /// Required accounts (2): /// - Faucet PDA account /// - Recipient vault PDA account - Transfer { + TransferVault { vault_program_id: ProgramId, recipient_id: AccountId, amount: u128, }, + + /// Transfers native tokens from system faucet directly to a recipient account. + /// + /// Required accounts (2): + /// - Faucet PDA account + /// - Recipient account + TransferDirect { amount: u128 }, } #[must_use] diff --git a/sequencer/core/src/config.rs b/sequencer/core/src/config.rs index 371ebc89..6609f58c 100644 --- a/sequencer/core/src/config.rs +++ b/sequencer/core/src/config.rs @@ -22,6 +22,9 @@ pub enum GenesisAction { account_id: AccountId, balance: u128, }, + SupplyBridgeAccount { + balance: u128, + }, } // TODO: Provide default values diff --git a/sequencer/core/src/lib.rs b/sequencer/core/src/lib.rs index c6606145..17421d4b 100644 --- a/sequencer/core/src/lib.rs +++ b/sequencer/core/src/lib.rs @@ -363,6 +363,9 @@ fn build_genesis_state(config: &SequencerConfig) -> (nssa::V03State, Vec build_supply_account_genesis_transaction(account_id, *balance), + GenesisAction::SupplyBridgeAccount { balance } => { + build_supply_bridge_account_genesis_transaction(*balance) + } }) .chain(std::iter::once(clock_invocation(0))) .inspect(|tx| { @@ -388,7 +391,7 @@ fn build_supply_account_genesis_transaction( faucet_program_id, vec![nssa::system_faucet_account_id(), recipient_vault_id], vec![], - faucet_core::Instruction::Transfer { + faucet_core::Instruction::TransferVault { vault_program_id, recipient_id: *account_id, amount: balance, @@ -400,6 +403,22 @@ fn build_supply_account_genesis_transaction( PublicTransaction::new(message, witness_set) } +fn build_supply_bridge_account_genesis_transaction(balance: u128) -> PublicTransaction { + let faucet_program_id = Program::faucet().id(); + let bridge_account_id = nssa::system_bridge_account_id(); + + let message = Message::try_new( + faucet_program_id, + vec![nssa::system_faucet_account_id(), bridge_account_id], + vec![], + faucet_core::Instruction::TransferDirect { amount: balance }, + ) + .expect("Failed to serialize bridge genesis transfer instruction"); + let witness_set = nssa::public_transaction::WitnessSet::from_raw_parts(vec![]); + + PublicTransaction::new(message, witness_set) +} + /// 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() { diff --git a/sequencer/service/Cargo.toml b/sequencer/service/Cargo.toml index beed6be2..26a32d5c 100644 --- a/sequencer/service/Cargo.toml +++ b/sequencer/service/Cargo.toml @@ -11,9 +11,13 @@ workspace = true common.workspace = true nssa.workspace = true mempool.workspace = true +bridge_core.workspace = true +vault_core.workspace = true sequencer_core = { workspace = true, features = ["testnet"] } sequencer_service_protocol.workspace = true sequencer_service_rpc = { workspace = true, features = ["server"] } +logos-blockchain-zone-sdk.workspace = true +logos-blockchain-core.workspace = true clap = { workspace = true, features = ["derive", "env"] } anyhow.workspace = true diff --git a/sequencer/service/src/lib.rs b/sequencer/service/src/lib.rs index 319b75ad..ecedfd7c 100644 --- a/sequencer/service/src/lib.rs +++ b/sequencer/service/src/lib.rs @@ -1,11 +1,21 @@ -use std::{net::SocketAddr, sync::Arc, time::Duration}; +use std::{collections::HashSet, net::SocketAddr, sync::Arc, time::Duration}; use anyhow::{Context as _, Result, anyhow}; +#[cfg(not(feature = "standalone"))] +use borsh::BorshDeserialize; use bytesize::ByteSize; use common::transaction::NSSATransaction; +#[cfg(not(feature = "standalone"))] +use futures::StreamExt as _; use futures::never::Never; use jsonrpsee::server::ServerHandle; +#[cfg(not(feature = "standalone"))] +use log::warn; use log::{error, info}; +#[cfg(not(feature = "standalone"))] +use logos_blockchain_zone_sdk::{ + CommonHttpClient, ZoneMessage, adapter::Node as _, adapter::NodeHttpClient, +}; use mempool::MemPoolHandle; #[cfg(not(feature = "standalone"))] use sequencer_core::SequencerCore; @@ -19,6 +29,19 @@ pub mod service; const REQUEST_BODY_MAX_SIZE: ByteSize = ByteSize::mib(10); +#[cfg(not(feature = "standalone"))] +#[derive(Clone, Debug, BorshDeserialize)] +struct DepositMetadata { + recipient_id: nssa::AccountId, +} + +#[cfg(not(feature = "standalone"))] +impl DepositMetadata { + fn decode(bytes: &[u8]) -> Result { + Self::try_from_slice(bytes) + } +} + /// Handle to manage the sequencer and its tasks. /// /// Implements `Drop` to ensure all tasks are aborted and the RPC server is stopped when dropped. @@ -27,6 +50,7 @@ pub struct SequencerHandle { /// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`. server_handle: Option, main_loop_handle: JoinHandle>, + bedrock_deposit_loop_handle: Option>>, } impl SequencerHandle { @@ -34,11 +58,13 @@ impl SequencerHandle { addr: SocketAddr, server_handle: ServerHandle, main_loop_handle: JoinHandle>, + bedrock_deposit_loop_handle: Option>>, ) -> Self { Self { addr, server_handle: Some(server_handle), main_loop_handle, + bedrock_deposit_loop_handle, } } @@ -52,18 +78,25 @@ impl SequencerHandle { addr: _, server_handle, main_loop_handle, + bedrock_deposit_loop_handle, } = &mut self; let server_handle = server_handle.take().expect("Server handle is set"); - + let deposit_opt_fut = + futures::future::OptionFuture::from(bedrock_deposit_loop_handle.take()); tokio::select! { () = server_handle.stopped() => { Err(anyhow!("RPC Server stopped")) } res = main_loop_handle => { res - .context("Main loop task panicked")? - .context("Main loop exited unexpectedly") + .context("Main loop task panicked")? + .context("Main loop exited unexpectedly") + } + Some(res) = deposit_opt_fut => { + res + .context("Bedrock deposit loop task panicked")? + .context("Bedrock deposit loop exited unexpectedly") } } } @@ -78,10 +111,14 @@ impl SequencerHandle { addr: _, server_handle, main_loop_handle, + bedrock_deposit_loop_handle, } = self; let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped) - || main_loop_handle.is_finished(); + || main_loop_handle.is_finished() + || bedrock_deposit_loop_handle + .as_ref() + .is_some_and(JoinHandle::is_finished); !stopped } @@ -97,9 +134,13 @@ impl Drop for SequencerHandle { addr: _, server_handle, main_loop_handle, + bedrock_deposit_loop_handle, } = self; main_loop_handle.abort(); + if let Some(handle) = bedrock_deposit_loop_handle { + handle.abort(); + } let Some(handle) = server_handle else { return; @@ -114,16 +155,19 @@ impl Drop for SequencerHandle { pub async fn run(config: SequencerConfig, port: u16) -> Result { let block_timeout = config.block_create_timeout; let max_block_size = config.max_block_size; + #[cfg(not(feature = "standalone"))] + let bedrock_config = config.bedrock_config.clone(); let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await; info!("Sequencer core set up"); let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core)); + let mempool_handle_for_server = mempool_handle.clone(); let (server_handle, addr) = run_server( Arc::clone(&seq_core_wrapped), - mempool_handle, + mempool_handle_for_server, port, max_block_size.as_u64(), ) @@ -133,7 +177,26 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result info!("Starting main sequencer loop"); let main_loop_handle = tokio::spawn(main_loop(seq_core_wrapped, block_timeout)); - Ok(SequencerHandle::new(addr, server_handle, main_loop_handle)) + #[cfg(not(feature = "standalone"))] + let bedrock_deposit_loop_handle = { + info!("Starting Bedrock deposit listener loop"); + Some(tokio::spawn(bedrock_deposit_loop( + bedrock_config, + mempool_handle, + ))) + }; + #[cfg(feature = "standalone")] + let bedrock_deposit_loop_handle = { + let _ = mempool_handle; + None + }; + + Ok(SequencerHandle::new( + addr, + server_handle, + main_loop_handle, + bedrock_deposit_loop_handle, + )) } async fn run_server( @@ -182,3 +245,113 @@ async fn main_loop(seq_core: Arc>, block_timeout: Duration) info!("Waiting for new transactions"); } } + +#[cfg(not(feature = "standalone"))] +async fn bedrock_deposit_loop( + bedrock_config: BedrockConfig, + mempool_handle: MemPoolHandle, +) -> Result { + let basic_auth = bedrock_config.auth.map(Into::into); + let channel_id = bedrock_config.channel_id; + let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), bedrock_config.node_url); + let mut seen_deposits = HashSet::new(); + + loop { + let stream = match node.block_stream().await { + Ok(stream) => stream, + Err(err) => { + error!("Failed to start Bedrock deposit stream: {err}"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + }; + + let mut stream = std::pin::pin!(stream); + while let Some(block_event) = stream.next().await { + let block_id = block_event.block.header.id; + + let zone_messages = match node.zone_messages_in_block(block_id, channel_id).await { + Ok(messages) => messages, + Err(err) => { + warn!("Failed to fetch zone messages for Bedrock block {block_id}: {err}"); + continue; + } + }; + let mut zone_messages = std::pin::pin!(zone_messages); + + while let Some(msg) = zone_messages.next().await { + match msg { + ZoneMessage::Block(block) => { + info!("Observed Bedrock channel block id {:?}", block.id); + } + ZoneMessage::Deposit(deposit) => { + // Dedupe by stable payload to avoid replaying the same deposit. + let deposit_fingerprint = + format!("{:?}:{:?}", deposit.inputs, deposit.metadata); + if !seen_deposits.insert(deposit_fingerprint) { + continue; + } + + let metadata = match DepositMetadata::decode(&deposit.metadata) { + Ok(metadata) => metadata, + Err(err) => { + warn!("Skipping Bedrock Deposit with invalid metadata: {err}"); + continue; + } + }; + + let tx = match build_bridge_deposit_tx(&metadata) { + Ok(tx) => tx, + Err(err) => { + warn!("Skipping Bedrock Deposit due to tx build failure: {err:#}"); + continue; + } + }; + + info!( + "Observed Bedrock Deposit for recipient {recipient_id}, pushing to mempool", + recipient_id = metadata.recipient_id + ); + mempool_handle.push(tx).await.context( + "Mempool is closed while pushing Bedrock Deposit transaction", + )?; + } + ZoneMessage::Withdraw(_) => {} + } + } + } + + warn!("Bedrock deposit stream ended unexpectedly, reconnecting"); + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + +#[cfg(not(feature = "standalone"))] +fn build_bridge_deposit_tx(metadata: &DepositMetadata) -> Result { + // TODO: Remove this constant once we have a way to get the deposit amount from Bedrock deposit + // inputs. + const TEMPORARY_BRIDGE_DEPOSIT_AMOUNT: u128 = 1; + + let bridge_program_id = nssa::program::Program::bridge().id(); + let vault_program_id = nssa::program::Program::vault().id(); + let recipient_vault_id = + vault_core::compute_vault_account_id(vault_program_id, metadata.recipient_id); + + let message = nssa::public_transaction::Message::try_new( + bridge_program_id, + vec![nssa::system_bridge_account_id(), recipient_vault_id], + vec![], + bridge_core::Instruction::Deposit { + vault_program_id, + recipient_id: metadata.recipient_id, + amount: TEMPORARY_BRIDGE_DEPOSIT_AMOUNT, + }, + ) + .context("Failed to build bridge deposit message")?; + + let witness_set = nssa::public_transaction::WitnessSet::from_raw_parts(vec![]); + Ok(NSSATransaction::Public(nssa::PublicTransaction::new( + message, + witness_set, + ))) +} diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs index 00bdc74a..ef500873 100644 --- a/test_fixtures/src/config.rs +++ b/test_fixtures/src/config.rs @@ -143,7 +143,12 @@ pub fn genesis_from_accounts( balance: account.balance, }); - public_genesis.chain(private_genesis).collect() + let supply_bridge_account = GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }; + + public_genesis + .chain(private_genesis) + .chain(std::iter::once(supply_bridge_account)) + .collect() } pub fn wallet_config(sequencer_addr: SocketAddr) -> Result { @@ -184,10 +189,6 @@ pub fn addr_to_url(protocol: UrlProtocol, addr: SocketAddr) -> Result { url_string.parse().map_err(Into::into) } -fn bedrock_channel_id() -> ChannelId { - let channel_id: [u8; 32] = [0_u8, 1] - .repeat(16) - .try_into() - .unwrap_or_else(|_| unreachable!()); - ChannelId::from(channel_id) +pub fn bedrock_channel_id() -> ChannelId { + ChannelId::from([0_u8; 32]) } diff --git a/test_program_methods/guest/src/bin/faucet_chain_caller.rs b/test_program_methods/guest/src/bin/faucet_chain_caller.rs index 2e02982d..afaba792 100644 --- a/test_program_methods/guest/src/bin/faucet_chain_caller.rs +++ b/test_program_methods/guest/src/bin/faucet_chain_caller.rs @@ -30,7 +30,7 @@ fn main() { let chained_calls = vec![ChainedCall { program_id: faucet_program_id, - instruction_data: to_vec(&faucet_core::Instruction::Transfer { + instruction_data: to_vec(&faucet_core::Instruction::TransferVault { vault_program_id, recipient_id, amount,