From 182100c999689bef2b53ec4d7baec76a95992ffc Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 30 Sep 2026 21:13:51 +0530 Subject: [PATCH 01/14] Skip native Lightning startup on mainnet --- README.md | 37 +- bindings/c-ffi/README.md | 4 +- bindings/c-ffi/src/utils.rs | 12 + bindings/rgb_lightning_node.udl | 1 + openapi.yaml | 45 +- src/args.rs | 45 +- src/asset_link.rs | 10 +- src/error.rs | 24 +- src/ldk.rs | 1146 +++++++++++++++++++------------ src/lib.rs | 6 + src/main.rs | 6 +- src/mainnet_startup_tests.rs | 647 +++++++++++++++++ src/mainnet_state.rs | 447 ++++++++++++ src/mainnet_vss_tests.rs | 541 +++++++++++++++ src/node.rs | 9 +- src/node_info.rs | 191 ++++++ src/rgb.rs | 4 +- src/routes.rs | 191 +++--- src/sdk/mod.rs | 469 +++++++------ src/synced_kv_store.rs | 7 +- src/uniffi_api/README.md | 15 +- src/uniffi_api/state.rs | 1 + src/uniffi_api/tests.rs | 13 + src/uniffi_api/types.rs | 2 + src/utils.rs | 81 ++- 25 files changed, 3118 insertions(+), 836 deletions(-) create mode 100644 src/mainnet_startup_tests.rs create mode 100644 src/mainnet_state.rs create mode 100644 src/mainnet_vss_tests.rs create mode 100644 src/node_info.rs diff --git a/README.md b/README.md index abcc3694..98d841c5 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,36 @@ native SDK/bindings return the corresponding typed error. The configured node ne controls this restriction, even while the node is locked. REST authentication and request extraction still apply first; SDK argument conversion still applies before SDK execution. -Bitcoin/RGB on-chain APIs, including RGB invoices, transfers and asset linking, remain -available under their existing requirements. Shared administration, node/network info, -and node-identity message signing/verification retain their existing behavior. -Lightning APIs on supported non-mainnet networks are unchanged. Startup, background -services, and wallet/signing policies are unchanged. +Mainnet unlock starts the Bitcoin/RGB wallet, identity, signer and configured wallet +backup services without constructing the Lightning runtime. It does not start Lightning +chain synchronization, peer listeners, reconnect, gossip, event processing or sweeping. +The peer port is unused. The existing required `ldk_chain_sync` request field remains +accepted for compatibility; its backend and gossip settings are unused on mainnet. +The RGB wallet still requires a valid mainnet indexer and its existing proxy configuration. + +After unlock, Bitcoin/RGB on-chain APIs, including RGB invoices and transfers, remain +available under their existing requirements. Node identity and message signing retain +the same keys. `nodeinfo` reports zero active Lightning counts and balances and a null +RGS timestamp; these values are not wallet BTC balances. Mainnet `networkinfo` reads the +wallet indexer's current height on demand and returns an error if it cannot be read or +the indexer is on another network. Non-mainnet `networkinfo` continues to use LDK's height. +Lightning and wallet/signing policies on supported non-mainnet networks are unchanged. + +An existing mainnet wallet with persisted Lightning state returns HTTP 409 +`MainnetLightningState` during unlock, with a message beginning: + +> Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: + +This check is conservative: even empty channel-manager or sweeper snapshots from an +earlier on-chain-only installation require review. It also checks local pending +replication, peer history and the configured remote Lightning store. RLN preserves +these records and does not start LDK to inspect them. Do not delete records or clear +remote stores to bypass the check. An operator must review any channel, funding or +sweep obligations and arrange recovery with a release that can monitor them before +upgrading that wallet. Refusing unlock does not keep existing channels monitored. +Fresh mainnet wallets and wallets previously unlocked by this implementation can +unlock normally. RGB VSS restore and backup continue using the existing wallet store; +mainnet does not restore or replicate Lightning snapshots. Please be careful, this software is early alpha, we do not take any responsibility for loss of funds or any other issue you may encounter. @@ -103,7 +128,7 @@ cargo install --locked --path . --no-default-features --features transaction-syn ## Run In order to operate, the node will need: -- a bitcoind node (only for the `BlockSync` [sync mode](#sync-modes)) +- a bitcoind node (only for non-mainnet `BlockSync` [sync mode](#sync-modes)) - an indexer instance for RGB (electrum or esplora — forwarded to rgb-lib) Once services are running, daemons can be started. diff --git a/bindings/c-ffi/README.md b/bindings/c-ffi/README.md index c5df1926..600bd58f 100644 --- a/bindings/c-ffi/README.md +++ b/bindings/c-ffi/README.md @@ -53,7 +53,9 @@ When the node is configured for mainnet, Lightning operations return a failed `CResultString` whose error is prefixed with `Rln(LightningUnsupportedOnMainnet):` and contains: "RLN on mainnet currently supports only on-chain methods. Lightning APIs are not supported." On-chain and shared administrative APIs retain their existing -requirements. See the [native SDK availability documentation](../../src/uniffi_api/README.md#mainnet-api-availability). +requirements. Mainnet unlock does not start the Lightning runtime. Existing Lightning state +returns `Rln(MainnetLightningState):` and requires operator review, including empty +snapshots created by older releases. See the [native SDK availability documentation](../../src/uniffi_api/README.md#mainnet-api-availability). ## Memory ownership diff --git a/bindings/c-ffi/src/utils.rs b/bindings/c-ffi/src/utils.rs index 98c33722..9ad93dca 100644 --- a/bindings/c-ffi/src/utils.rs +++ b/bindings/c-ffi/src/utils.rs @@ -126,6 +126,7 @@ fn rln_variant_tag(e: &RlnError) -> &'static str { RlnError::FailedVssInit(_) => "FailedVssInit", RlnError::Internal(_) => "Internal", RlnError::LightningUnsupportedOnMainnet(_) => "LightningUnsupportedOnMainnet", + RlnError::MainnetLightningState(_) => "MainnetLightningState", } } @@ -264,6 +265,17 @@ pub(crate) fn require_signer( mod tests { use super::*; + #[test] + fn mainnet_legacy_state_error_preserves_category_and_message() { + let _ = rgb_lightning_node::take_last_api_error_detail(); + let message = "Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: local manager snapshot"; + let err = Error::from(RlnError::MainnetLightningState(message.into())); + assert_eq!( + format_error_for_ffi(&err), + format!("Rln(MainnetLightningState): {message}") + ); + } + #[test] fn mainnet_lightning_error_preserves_category_and_message() { let _ = rgb_lightning_node::take_last_api_error_detail(); diff --git a/bindings/rgb_lightning_node.udl b/bindings/rgb_lightning_node.udl index c60835ac..dbe504dd 100644 --- a/bindings/rgb_lightning_node.udl +++ b/bindings/rgb_lightning_node.udl @@ -162,6 +162,7 @@ enum RlnError { "FailedVssInit", "Internal", "LightningUnsupportedOnMainnet", + "MainnetLightningState", }; dictionary NodeInfo { diff --git a/openapi.yaml b/openapi.yaml index ba6d01b5..1cac960f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -14,7 +14,11 @@ info: Authentication and request extraction still apply before handler execution. Bitcoin/RGB on-chain APIs (including RGB invoices, transfers and asset linking) and shared administration/identity APIs retain their existing requirements. - Lightning APIs on supported non-mainnet networks are unchanged. + Mainnet unlock starts the wallet and signer without a Lightning runtime. + Existing opaque Lightning state requires operator review and causes HTTP 409 + MainnetLightningState; even empty snapshots from older releases are refused. + No Lightning state is deleted or resumed. Lightning APIs on supported + non-mainnet networks are unchanged. license: name: MIT url: https://mit-license.org/ @@ -1012,7 +1016,10 @@ paths: tags: - Other summary: Get network info - description: Get info on the Bitcoin network where the LN is running + description: >- + Get the configured Bitcoin network and height. On mainnet the height is + read from the wallet indexer on demand; an unavailable or wrong-network + indexer returns an error. Other networks report the LDK best-block height. responses: '200': description: Successful operation @@ -1025,7 +1032,10 @@ paths: tags: - Other summary: Get node info - description: Get the LN node's info + description: >- + Get node identity, wallet account keys and Lightning information. On mainnet, + active Lightning counts and amounts are zero and the RGS timestamp is null. + Lightning amounts do not represent the on-chain BTC wallet balance. responses: '200': description: Successful operation @@ -1345,7 +1355,14 @@ paths: tags: - Other summary: Unlock the node - description: Unlock a locked node + description: >- + Unlock a locked node. Mainnet starts wallet, signer and configured RGB backup + services without constructing or starting LDK. The required ldk_chain_sync + payload is parsed but its backend and gossip settings are unused on mainnet. + Existing Lightning state, including opaque empty snapshots from earlier + releases, requires operator recovery review and returns MainnetLightningState. + This refusal does not keep existing channels monitored; do not delete state + to bypass it. requestBody: content: application/json: @@ -1358,6 +1375,8 @@ paths: application/json: schema: $ref: '#/components/schemas/EmptyResponse' + '409': + $ref: '#/components/responses/MainnetLightningState' /vssbackup: post: tags: @@ -1472,6 +1491,24 @@ paths: description: VSS is not configured or unreachable components: responses: + MainnetLightningState: + description: Persisted mainnet Lightning state requires operator recovery review before wallet startup. + content: + application/json: + schema: + type: object + required: [name, code, error] + properties: + name: + type: string + code: + type: integer + error: + type: string + example: + name: MainnetLightningState + code: 409 + error: 'Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: local manager snapshot' LightningUnsupportedOnMainnet: description: Lightning APIs are unavailable on a node configured for mainnet. content: diff --git a/src/args.rs b/src/args.rs index a18387ca..aed66de8 100644 --- a/src/args.rs +++ b/src/args.rs @@ -147,12 +147,18 @@ pub(crate) fn parse_startup_args() -> Result { let toml = load_startup_toml(&args)?; let user_args = resolve_user_args(args, &matches, toml)?; - check_port_is_available(user_args.daemon_listening_port)?; - check_port_is_available(user_args.ldk_peer_listening_port)?; - + check_startup_ports(&user_args)?; Ok(user_args) } +fn check_startup_ports(args: &UserArgs) -> Result<(), AppError> { + check_port_is_available(args.daemon_listening_port)?; + if args.network != BitcoinNetwork::Mainnet { + check_port_is_available(args.ldk_peer_listening_port)?; + } + Ok(()) +} + fn load_startup_toml(args: &Args) -> Result { if let Some(path) = &args.config { return load_config_file(path); @@ -201,7 +207,7 @@ fn resolve_user_args( node.ldk_peer_listening_port .unwrap_or(args.ldk_peer_listening_port) }; - if daemon_listening_port == ldk_peer_listening_port { + if network != BitcoinNetwork::Mainnet && daemon_listening_port == ldk_peer_listening_port { return Err(AppError::InvalidConfig(format!( "daemon_listening_port and ldk_peer_listening_port cannot both be {daemon_listening_port}" ))); @@ -380,6 +386,37 @@ mod tests { assert!(matches!(res, Err(AppError::InvalidConfig(_)))); } + #[test] + fn mainnet_ignores_the_unused_peer_port() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let peer_port = listener.local_addr().unwrap().port(); + let mut args = resolve( + &base(&["--network", "mainnet"]), + &format!("[node]\ndaemon_listening_port = 0\nldk_peer_listening_port = {peer_port}\n"), + ) + .unwrap(); + check_startup_ports(&args).unwrap(); + assert!( + matches!(listener.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + + args.network = BitcoinNetwork::Regtest; + assert!( + matches!(check_startup_ports(&args), Err(AppError::UnavailablePort(port)) if port == peer_port) + ); + } + + #[test] + fn mainnet_allows_unused_peer_port_equal_to_rest_port() { + let args = resolve( + &base(&["--network", "mainnet"]), + "[node]\ndaemon_listening_port = 4000\nldk_peer_listening_port = 4000\n", + ) + .unwrap(); + assert_eq!(args.daemon_listening_port, 4000); + } + #[test] fn auth_from_file() { let argv = vec!["rln", "/tmp/storage"]; diff --git a/src/asset_link.rs b/src/asset_link.rs index da8ef685..f5fb5c32 100644 --- a/src/asset_link.rs +++ b/src/asset_link.rs @@ -44,7 +44,7 @@ use crate::{ rgb::get_rgb_channel_info_optional, utils::{ description_from_invoice, description_hash_from_invoice, get_current_timestamp, get_route, - hex_str, new_jsonrpc_request_id, UnlockedAppState, + hex_str, new_jsonrpc_request_id, CommonState, LightningState, }, }; @@ -414,7 +414,7 @@ impl CustomMessageHandler for AssetLinkMessageHandler { } pub(crate) fn create_asset_link( - unlocked_state: &UnlockedAppState, + unlocked_state: &CommonState, params: AssetLinkRequest, ) -> Result { if unlocked_state.external_signer_mode { @@ -511,7 +511,7 @@ pub(crate) fn create_asset_link( } pub(crate) fn find_linked_asset_channel( - unlocked_state: &UnlockedAppState, + unlocked_state: &LightningState, contract_id: ContractId, asset_amount: u64, amt_msat: u64, @@ -595,7 +595,7 @@ pub(crate) fn find_linked_asset_channel( } pub(crate) fn has_sufficient_asset_channel( - unlocked_state: &UnlockedAppState, + unlocked_state: &LightningState, contract_id: ContractId, asset_amount: u64, amt_msat: u64, @@ -620,7 +620,7 @@ pub(crate) fn has_sufficient_asset_channel( #[allow(clippy::too_many_arguments)] pub(crate) async fn send_linked_asset_payment( - unlocked_state: &UnlockedAppState, + unlocked_state: &LightningState, invoice: &Bolt11Invoice, contract_id: ContractId, linked_contract_id: ContractId, diff --git a/src/error.rs b/src/error.rs index 7fc36eda..0b6952fc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -329,6 +329,9 @@ pub enum APIError { #[error("RLN on mainnet currently supports only on-chain methods. Lightning APIs are not supported.")] LightningUnsupportedOnMainnet, + #[error("Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: {0}")] + MainnetLightningState(String), + #[error("Node is locked (hint: call unlock)")] LockedNode, @@ -716,7 +719,7 @@ impl IntoResponse for APIError { APIError::FailedBitcoindConnection(_) | APIError::NetworkMismatch(_, _) => { (StatusCode::FORBIDDEN, self.to_string(), self.name()) } - APIError::InvoiceAlreadyClaimed => { + APIError::InvoiceAlreadyClaimed | APIError::MainnetLightningState(_) => { (StatusCode::CONFLICT, self.to_string(), self.name()) } APIError::ExternalSignerMismatch => { @@ -837,6 +840,25 @@ mod tests { ); } + #[tokio::test] + async fn mainnet_legacy_state_response_preserves_name_and_message() { + let response = + APIError::MainnetLightningState("local manager snapshot".into()).into_response(); + assert_eq!(response.status(), StatusCode::CONFLICT); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + body, + serde_json::json!({ + "code": 409, + "name": "MainnetLightningState", + "error": "Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: local manager snapshot" + }) + ); + } + #[test] fn unsupported_schema_maps_to_dedicated_error() { let err = APIError::from(RgbLibError::UnsupportedSchema { diff --git a/src/ldk.rs b/src/ldk.rs index 0a7e46e6..a218a819 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -56,7 +56,6 @@ use lightning::sign::{KeysManager, OutputSpender, SpendableOutputDescriptor}; use lightning::chain; #[cfg(feature = "vss")] use lightning::chain::chainmonitor::AsyncPersister; -#[cfg(any(not(feature = "vss"), test))] use lightning::sign::NodeSigner; #[cfg(feature = "vss")] use lightning::sign::PeerStorageKey; @@ -111,8 +110,7 @@ use std::convert::TryInto; use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; use std::net::ToSocketAddrs; -use std::net::{SocketAddr, TcpListener}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::str::FromStr; #[cfg(test)] use std::sync::atomic::AtomicUsize; @@ -121,7 +119,6 @@ use std::sync::{Arc, Mutex, MutexGuard, RwLock, Weak}; #[cfg(any(test, feature = "vss"))] use std::time::Instant; use std::time::{Duration, SystemTime}; -use time::OffsetDateTime; use tokio::runtime::Handle; use tokio::sync::watch::Sender; use tokio::task::JoinHandle; @@ -188,11 +185,11 @@ use crate::signer::{ }; use crate::swap::{SwapData, SwapInfo}; use crate::utils::{ - check_port_is_available, connect_peer_if_necessary, description_from_invoice, - description_hash_from_invoice, do_connect_peer, get_current_timestamp, - get_max_local_rgb_amount, hex_str, validate_and_parse_payment_hash, - validate_and_parse_payment_preimage, AppState, StaticState, UnlockedAppState, FATAL_ERROR, - PROXY_ENDPOINT_LOCAL, PROXY_ENDPOINT_PUBLIC, + connect_peer_if_necessary, description_from_invoice, description_hash_from_invoice, + do_connect_peer, get_current_timestamp, get_max_local_rgb_amount, hex_str, + validate_and_parse_payment_hash, validate_and_parse_payment_preimage, AppState, CommonState, + LightningState, StaticState, UnlockedAppState, FATAL_ERROR, PROXY_ENDPOINT_LOCAL, + PROXY_ENDPOINT_PUBLIC, }; const RGB_TRANSFER_CHAN_EXPIRATION_SECS: u64 = 86400; @@ -348,6 +345,8 @@ pub(crate) struct LdkBackgroundServices { peer_manager: Arc, bp_exit: Sender<()>, background_processor: Option>>, + shutdown: CancellationToken, + tasks: Vec>, } #[derive(Clone, Debug)] @@ -517,7 +516,7 @@ fn persist_staged_inbound_payment( Ok(()) } -impl UnlockedAppState { +impl LightningState { pub(crate) fn add_maker_swap(&self, payment_hash: PaymentHash, swap: SwapData) { let mut maker_swaps = self.get_maker_swaps(); maker_swaps.swaps.insert(payment_hash, swap); @@ -1320,7 +1319,7 @@ impl CustomMsgPeerAccessControl for LiveChannelAccess { } struct NodeAssetLinkAuthorizer { - unlocked_state_weak: Weak, + unlocked_state_weak: Weak, channel_manager: Arc, kv_store: Arc, taker_swaps: Arc>, @@ -1885,7 +1884,7 @@ fn abort_funding( /// transaction was broadcast. For colored channels this fails the pending RGB /// batch transfer; for vanilla channels it aborts the pending vanilla tx that /// was created (and locked the UTXOs) during `FundingGenerationReady`. -async fn handle_open_chan_fail(channel_id: &ChannelId, unlocked_state: Arc) { +async fn handle_open_chan_fail(channel_id: &ChannelId, unlocked_state: Arc) { let channel_id_hex = channel_id.0.as_hex().to_string(); if let Some(rgb_info) = get_rgb_channel_info_optional(channel_id, true, unlocked_state.kv_store.as_ref()) @@ -1939,7 +1938,7 @@ async fn handle_open_chan_fail(channel_id: &ChannelId, unlocked_state: Arc, + unlocked_state: Arc, temporary_channel_id: &ChannelId, unsigned_psbt: &str, is_colored: bool, @@ -2008,7 +2007,7 @@ async fn abort_staged_standard_funding( async fn handle_ldk_events( event: Event, - unlocked_state: Arc, + unlocked_state: Arc, static_state: Arc, ) -> Result<(), ReplayEvent> { match event { @@ -4303,9 +4302,8 @@ mod vss_bootstrap_identity_tests { /// Restore the RGB wallet directory from VSS if (a) VSS is configured for this /// node, (b) the local wallet directory for `expected_fingerprint` is absent, -/// and (c) VSS has a backup for the given store. Mirrors the KV-side -/// auto-restore policy at `start_ldk`'s top: silent no-op when nothing is on -/// VSS, hard error otherwise unless `allow_empty_restore` is set. +/// and (c) VSS has a backup for the given store. Nothing to restore is a no-op; +/// other restore failures abort startup unless `allow_empty_restore` is set. #[cfg(feature = "vss")] pub(crate) async fn maybe_restore_rgb_from_vss( vss_url: &str, @@ -4708,13 +4706,393 @@ fn resolve_indexer_url<'a>( request.or(config).ok_or(APIError::MissingIndexerUrl) } -pub(crate) async fn start_ldk( +/// Common startup resources. No Lightning backend or worker is created here. +struct NodeStartup { + app_state: Arc, + unlock_request: UnlockRequest, + internal_mnemonic: Option, + external_signer_mode: bool, + external_bootstrap: Option, + external_signer: Option>, + external_node_id: Option, + external_signer_link_watch: Option>, + kv_store: Arc, + indexer_url: String, + #[cfg(feature = "transaction-sync")] + indexer_protocol: rgb_lib::wallet::rust_only::IndexerProtocol, + proxy_endpoint: String, + #[cfg(feature = "vss")] + monitor_kv_store: Option>, + #[cfg(feature = "vss")] + bp_local_kv_store: Arc, + #[cfg(feature = "vss")] + vss_identity: Option, + #[cfg(feature = "vss")] + vss_restored_keys: usize, + #[cfg(feature = "vss")] + fence_guard: Option, +} + +async fn check_mainnet_startup_state( + app_state: &Arc, + #[cfg(feature = "vss")] remote: Option>, +) -> Result<(), APIError> { + let database = app_state.db(); + let data_dir = app_state.static_state.ldk_data_dir.clone(); + tokio::task::spawn_blocking(move || { + crate::mainnet_state::check_mainnet_legacy_state( + database.as_ref(), + &data_dir, + #[cfg(feature = "vss")] + remote.as_deref(), + ) + }) + .await + .map_err(|e| APIError::Unexpected(format!("mainnet state inspection failed: {e}")))? +} + +impl NodeStartup { + fn create_signer(&self) -> ActiveSignerRef { + let external_signer = &self.external_signer; + let internal_mnemonic = &self.internal_mnemonic; + let network: Network = self.app_state.static_state.network.into(); + let ldk_data_dir_path = self.app_state.static_state.ldk_data_dir.clone(); + let kv_store = &self.kv_store; + // LDK signing: internal mode uses `KeysManager` from the mnemonic-derived LDK seed (BIP32 child + // 535 of the master xpriv). External mode uses `ExternalSigner` only; inbound / peer_storage / + // receive_auth key material comes from bootstrap hex fields (see `ExternalSigner::from_attachment`). + let cur = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap(); + + let keys_manager: ActiveSignerRef = if let Some(s) = external_signer.as_ref() { + Arc::new(DynRlnSigner::from_external(Arc::clone(s))) + } else { + let mnemonic = internal_mnemonic + .as_ref() + .expect("internal mnemonic must be present when external signer is not configured"); + let ldk_seed: [u8; 32] = { + let xkey: ExtendedKey = mnemonic + .clone() + .into_extended_key() + .expect("a valid key should have been provided"); + let master_xprv = &xkey + .into_xprv(network.into()) + .expect("should be possible to get an extended private key"); + let xprv: Xpriv = master_xprv + .derive_priv(&Secp256k1_30::new(), &ChildNumber::Hardened { index: 535 }) + .unwrap(); + xprv.private_key.secret_bytes() + }; + let internal_keys_manager = Arc::new(KeysManager::new( + &ldk_seed, + cur.as_secs(), + cur.subsec_nanos(), + true, + ldk_data_dir_path.clone(), + Arc::clone(kv_store) as Arc, + )); + Arc::new(DynRlnSigner::from_internal(internal_keys_manager)) + }; + keys_manager + } + + async fn create_common_state( + &mut self, + signer: ActiveSignerRef, + entropy_source: Arc, + ) -> Result, APIError> { + let static_state = &self.app_state.static_state; + let external_signer_mode = self.external_signer_mode; + let external_bootstrap = &self.external_bootstrap; + let internal_mnemonic = &self.internal_mnemonic; + let bitcoin_network = static_state.network; + let kv_store = &self.kv_store; + let indexer_url = &self.indexer_url; + let unlock_request = &self.unlock_request; + #[cfg(feature = "vss")] + let vss_identity = &self.vss_identity; + // Prepare the RGB wallet + let (account_xpub_vanilla, account_xpub_colored, master_fingerprint, rgb_wallet_mnemonic) = + if external_signer_mode { + let bootstrap = external_bootstrap.clone().ok_or_else(|| { + APIError::ExternalSignerProtocolError( + "missing external bootstrap in external mode".to_string(), + ) + })?; + ( + bootstrap.identity.account_xpub_vanilla, + bootstrap.identity.account_xpub_colored, + bootstrap.identity.master_fingerprint, + None, + ) + } else { + let mnemonic_str = internal_mnemonic + .as_ref() + .ok_or_else(|| { + APIError::ExternalSignerProtocolError( + "missing internal mnemonic in internal mode".to_string(), + ) + })? + .to_string(); + let (_, account_xpub_vanilla, _) = get_account_data( + &bitcoin_network, + &mnemonic_str, + false, + WitnessVersion::Taproot, + ) + .unwrap(); + let (_, account_xpub_colored, master_fingerprint) = get_account_data( + &bitcoin_network, + &mnemonic_str, + true, + WitnessVersion::Taproot, + ) + .unwrap(); + ( + account_xpub_vanilla.to_string(), + account_xpub_colored.to_string(), + master_fingerprint.to_string(), + Some(mnemonic_str.clone()), + ) + }; + let data_dir = static_state + .storage_dir_path + .clone() + .to_string_lossy() + .to_string(); + + // Pull the RGB wallet down from VSS before constructing it locally, when + // VSS is configured and the local wallet directory for this identity's + // fingerprint is absent. This wallet stream is shared by both network paths; + // Lightning KV recovery is separately restricted to supported networks. + #[cfg(feature = "vss")] + if let (Some(ref vss_url), Some(ref identity)) = (&static_state.vss_url, &vss_identity) { + let rgb_store_id = format!("{}_rgb", identity.pubkey_hex); + maybe_restore_rgb_from_vss( + vss_url, + rgb_store_id, + identity.signing_key, + &static_state.storage_dir_path, + &master_fingerprint.to_string(), + static_state.vss_allow_empty_restore, + ) + .await?; + } + + let keys = SinglesigKeys { + account_xpub_vanilla: account_xpub_vanilla.clone(), + account_xpub_colored: account_xpub_colored.clone(), + vanilla_keychain: None, + master_fingerprint: master_fingerprint.clone(), + mnemonic: rgb_wallet_mnemonic, + witness_version: WitnessVersion::Taproot, + }; + let reuse_addresses = static_state.reuse_addresses; + let indexer_url_owned = indexer_url.to_string(); + let eth_rpc_url = unlock_request + .eth_rpc_url + .clone() + .or_else(|| static_state.config.chain.eth_rpc_url.clone()); + #[cfg(feature = "vss")] + let rgb_vss_backup = match (&static_state.vss_url, &vss_identity) { + (Some(vss_url), Some(identity)) => Some(( + vss_url.clone(), + format!("{}_rgb", identity.pubkey_hex), + identity.signing_key, + )), + _ => None, + }; + // go_online and configure_vss_backup drive blocking rgb-lib HTTP clients; + // run them off the async runtime so they don't fail on a single-vCPU host. + let (rgb_wallet, rgb_online) = tokio::task::spawn_blocking(move || { + let mut rgb_wallet = RgbLibWallet::new( + WalletData { + data_dir, + bitcoin_network, + database_type: DatabaseType::Sqlite, + max_allocations_per_utxo: 1, + supported_schemas: supported_asset_schemas( + bitcoin_network, + eth_rpc_url.is_some(), + ), + reuse_addresses, + }, + keys, + ) + .expect("valid rgb-lib wallet"); + let rgb_online = rgb_wallet.go_online(OnlineOptions { + indexer_url: indexer_url_owned, + skip_consistency_check: false, + vanilla_sync_lookback: 20, + eth_rpc_url, + })?; + #[cfg(feature = "vss")] + if let Some((vss_url, rgb_store_id, signing_key)) = rgb_vss_backup { + let vss_config = + rgb_lib::wallet::vss::VssBackupConfig::new(vss_url, rgb_store_id, signing_key) + .with_encryption(true) + .with_auto_backup(true) + .with_backup_mode(rgb_lib::wallet::vss::VssBackupMode::Blocking); + // Fail closed: a misconfigured backup must not silently run local-only. + rgb_wallet.configure_vss_backup(vss_config).map_err(|e| { + APIError::FailedVssInit(format!( + "Failed to configure VSS backup for RGB wallet: {e}" + )) + })?; + tracing::info!("VSS auto-backup (blocking) enabled for RGB wallet"); + // Auto-backup only tracks local changes; an empty remote (fresh + // wallet or wiped store) needs an explicit upload. + if let Some(client) = rgb_wallet.vss_client() { + let rt = client.handle().clone(); + let info = rt + .block_on(rgb_wallet.vss_backup_info(&client)) + .map_err(|e| APIError::FailedVssInit(format!("VSS backup info: {e}")))?; + if !info.backup_exists { + rt.block_on(rgb_wallet.vss_backup(&client)).map_err(|e| { + APIError::FailedVssInit(format!("initial RGB VSS backup failed: {e}")) + })?; + tracing::info!("Uploaded RGB wallet backup to empty VSS store"); + } + } + } + Ok::<_, APIError>((rgb_wallet, rgb_online)) + }) + .await + .map_err(|e| APIError::Unexpected(format!("rgb-lib wallet setup task failed: {e}")))??; + save_config( + &static_state.db(), + kv_store.as_ref(), + CONFIG_WALLET_FINGERPRINT, + &master_fingerprint, + )?; + save_config( + &static_state.db(), + kv_store.as_ref(), + CONFIG_WALLET_ACCOUNT_XPUB_COLORED, + &account_xpub_colored, + )?; + save_config( + &static_state.db(), + kv_store.as_ref(), + CONFIG_WALLET_ACCOUNT_XPUB_VANILLA, + &account_xpub_vanilla, + )?; + save_config( + &static_state.db(), + kv_store.as_ref(), + CONFIG_WALLET_MASTER_FINGERPRINT, + &master_fingerprint, + )?; + + // No second VssBackupClient is constructed here: the manual /vssbackup + // and /vssbackupinfo routes use the wallet's own client, retrievable via + // `wallet.vss_client()` (R-lib.1 in rgb-lib's PR #31). Keeping a single + // client per stream avoids running two tokio runtimes for the same + // backups and removes the race between the two clients writing + // overlapping state. + let rgb_wallet_wrapper = Arc::new(RgbLibWalletWrapper::new( + Arc::new(Mutex::new(rgb_wallet)), + rgb_online, + )); + + let node_id = match self.external_node_id.as_deref() { + Some(node_id) => PublicKey::from_str(node_id).map_err(|error| { + APIError::ExternalSignerProtocolError(format!( + "invalid bootstrap node identity: {error}" + )) + })?, + None => signer + .get_node_id(lightning::sign::Recipient::Node) + .map_err(|_| { + APIError::Unexpected("failed to read signer node identity".to_string()) + })?, + }; + Ok(Arc::new(CommonState { + config: Arc::clone(&static_state.config), + signer, + entropy_source, + kv_store: Arc::clone(kv_store), + rgb_wallet_wrapper, + proxy_endpoint: self.proxy_endpoint.clone(), + external_signer_mode, + external_signer: self.external_signer.clone(), + external_node_id: self.external_node_id.clone(), + node_id, + indexer_url: self.indexer_url.clone(), + #[cfg(feature = "vss")] + persistence_shutdown: CancellationToken::new(), + #[cfg(feature = "vss")] + persistence_worker: Mutex::new(None), + })) + } +} + +/// Unlock the common wallet/session, constructing Lightning only where supported. +pub(crate) async fn start_node( + app_state: Arc, + key_source: NodeKeySource, + unlock_request: UnlockRequest, +) -> Result<(Option, Arc), APIError> { + if app_state.cancel_token.is_cancelled() { + return Err(APIError::Unexpected("Node is shutting down".to_string())); + } + #[allow(unused_mut)] + let mut setup = prepare_node(app_state, key_source, unlock_request).await?; + let result = if setup.app_state.static_state.network == BitcoinNetwork::Mainnet { + let common = setup + .create_common_state(setup.create_signer(), Arc::new(SystemEntropySource)) + .await?; + #[cfg(feature = "vss")] + if let Some(guard) = setup.fence_guard.as_mut() { + guard.disarm(); + } + ( + None, + Arc::new(UnlockedAppState { + common, + lightning: None, + }), + ) + } else { + start_lightning(setup).await? + }; + #[cfg(feature = "vss")] + start_persistence_worker(&result.1.common); + Ok(result) +} + +#[cfg(feature = "vss")] +fn start_persistence_worker(common: &Arc) { + if !common.kv_store.has_remote() { + return; + } + let store = Arc::clone(&common.kv_store); + let shutdown = common.persistence_shutdown.clone(); + let worker = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + _ = interval.tick() => {} + } + let store = Arc::clone(&store); + if let Err(e) = tokio::task::spawn_blocking(move || store.drain_pending()).await { + tracing::error!(error = %e, "periodic VSS drain task failed"); + } + } + }); + *common.persistence_worker.lock().unwrap() = Some(worker); +} + +async fn prepare_node( app_state: Arc, key_source: NodeKeySource, mut unlock_request: UnlockRequest, -) -> Result<(LdkBackgroundServices, Arc), APIError> { - let gossip_source_config = unlock_request.gossip_source.clone().unwrap_or_default(); +) -> Result { let static_state = &app_state.static_state; + let mainnet = static_state.network == BitcoinNetwork::Mainnet; // Unlock request params take precedence, the config file provides defaults. let file_config = &static_state.config; @@ -4848,81 +5226,83 @@ pub(crate) async fn start_ldk( } })); - let monitor_kv_store = Arc::new(RemoteFirstKvStore::new( - Arc::clone(&local_kv_store), - Some(Arc::clone(&vss_kv_store)), - )); + if mainnet { + check_mainnet_startup_state(&app_state, Some(Arc::clone(&vss_kv_store))).await?; + } + let monitor_kv_store = (!mainnet).then(|| { + Arc::new(RemoteFirstKvStore::new( + Arc::clone(&local_kv_store), + Some(Arc::clone(&vss_kv_store)), + )) + }); let synced = Arc::new(SyncedKvStore::with_vss(local_kv_store, vss_kv_store)); - // Auto-restore from VSS if local DB has no channel manager data. - // On failure: abort unlock unless --vss-allow-empty-restore was set. - // Starting a recovering node with empty local state can lose funds - // (no channel monitors → can't watch chain), so we refuse by default. - let has_local_data = synced - .read( - CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, - CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, - CHANNEL_MANAGER_PERSISTENCE_KEY, - ) - .is_ok(); - if !has_local_data { - match synced.restore_from_vss(false) { - Ok(0) => tracing::info!("No VSS backup data found, starting fresh"), - Ok(n) => { - vss_restored_keys = n; - tracing::info!(keys_restored = n, "Restored node KV state from VSS"); - } - Err(e) => { - if static_state.vss_allow_empty_restore { - tracing::warn!( - error = %e, - "VSS restore failed; starting fresh due to --vss-allow-empty-restore" - ); - } else { - return Err(APIError::FailedVssInit(format!( - "VSS restore failed: {e}. Pass --vss-allow-empty-restore \ + if !mainnet { + // Auto-restore from VSS if local DB has no channel manager data. + // On failure: abort unlock unless --vss-allow-empty-restore was set. + // Starting a recovering node with empty local state can lose funds + // (no channel monitors → can't watch chain), so we refuse by default. + let has_local_data = synced + .read( + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + ) + .is_ok(); + if !has_local_data { + match synced.restore_from_vss(false) { + Ok(0) => tracing::info!("No VSS backup data found, starting fresh"), + Ok(n) => { + vss_restored_keys = n; + tracing::info!(keys_restored = n, "Restored node KV state from VSS"); + } + Err(e) => { + if static_state.vss_allow_empty_restore { + tracing::warn!( + error = %e, + "VSS restore failed; starting fresh due to --vss-allow-empty-restore" + ); + } else { + return Err(APIError::FailedVssInit(format!( + "VSS restore failed: {e}. Pass --vss-allow-empty-restore \ to start with an empty local state instead (UNSAFE if \ you previously had active channels)." - ))); + ))); + } } } + } else { + // Local state is authoritative: refill whatever the remote lacks + // (wiped or partial store) without overwriting what it holds. + synced.push_missing_to_vss().map_err(|e| { + APIError::FailedVssInit(format!("VSS resync of local state failed: {e}")) + })?; } - } else { - // Local state is authoritative: refill whatever the remote lacks - // (wiped or partial store) without overwriting what it holds. - synced.push_missing_to_vss().map_err(|e| { - APIError::FailedVssInit(format!("VSS resync of local state failed: {e}")) - })?; } (synced, monitor_kv_store) } else { - let monitor_kv_store = Arc::new(RemoteFirstKvStore::new(Arc::clone(&local_kv_store), None)); + if mainnet { + check_mainnet_startup_state(&app_state, None).await?; + } + let monitor_kv_store = (!mainnet) + .then(|| Arc::new(RemoteFirstKvStore::new(Arc::clone(&local_kv_store), None))); let synced = Arc::new(SyncedKvStore::local_only(local_kv_store)); (synced, monitor_kv_store) }; #[cfg(not(feature = "vss"))] - let kv_store = Arc::new(SyncedKvStore::local_only(local_kv_store)); - - #[cfg(feature = "vss")] - let bp_kv_store: BpKvStore = Arc::new(crate::async_kv_store::BpKvStoreRouter::new( - Arc::clone(&monitor_kv_store), - bp_local_kv_store, - Arc::clone(&kv_store), - )); - #[cfg(not(feature = "vss"))] - let bp_kv_store: BpKvStore = KVStoreSyncWrapper(Arc::clone(&kv_store)); + let kv_store = { + if mainnet { + check_mainnet_startup_state(&app_state).await?; + } + Arc::new(SyncedKvStore::local_only(local_kv_store)) + }; // Sync config from database to KVStore sync_config_to_kvstore(&static_state.db(), kv_store.as_ref())?; - let ldk_data_dir = static_state.ldk_data_dir.clone(); - let ldk_data_dir_path = PathBuf::from(&ldk_data_dir); - let logger = static_state.logger.clone(); let bitcoin_network = static_state.network; - let network: Network = bitcoin_network.into(); - let ldk_peer_listening_port = static_state.ldk_peer_listening_port; // RGB setup let indexer_url = resolve_indexer_url( @@ -4962,6 +5342,113 @@ pub(crate) async fn start_ldk( &bitcoin_network.to_string(), )?; + let indexer_url = indexer_url.to_string(); + let proxy_endpoint = proxy_endpoint.to_string(); + Ok(NodeStartup { + app_state, + unlock_request, + internal_mnemonic, + external_signer_mode, + external_bootstrap, + external_signer, + external_node_id, + external_signer_link_watch, + kv_store, + indexer_url, + #[cfg(feature = "transaction-sync")] + indexer_protocol, + proxy_endpoint, + #[cfg(feature = "vss")] + monitor_kv_store, + #[cfg(feature = "vss")] + bp_local_kv_store, + #[cfg(feature = "vss")] + vss_identity, + #[cfg(feature = "vss")] + vss_restored_keys, + #[cfg(feature = "vss")] + fence_guard, + }) +} + +async fn start_lightning( + #[allow(unused_mut)] mut setup: NodeStartup, +) -> Result<(Option, Arc), APIError> { + // Defense in depth: no chain backend, manager or worker precedes this check. + setup.app_state.check_lightning_supported()?; + let app_state = Arc::clone(&setup.app_state); + let static_state = &app_state.static_state; + let unlock_request = setup.unlock_request.clone(); + let gossip_source_config = unlock_request.gossip_source.clone().unwrap_or_default(); + let kv_store = Arc::clone(&setup.kv_store); + let external_signer_mode = setup.external_signer_mode; + let internal_mnemonic = setup.internal_mnemonic.clone(); + let external_bootstrap = setup.external_bootstrap.clone(); + let external_signer = setup.external_signer.clone(); + let external_signer_link_watch = setup.external_signer_link_watch.clone(); + #[cfg(feature = "transaction-sync")] + let indexer_url = setup.indexer_url.as_str(); + #[cfg(feature = "transaction-sync")] + let indexer_protocol = setup.indexer_protocol.clone(); + let ldk_data_dir = static_state.ldk_data_dir.clone(); + let ldk_data_dir_path = ldk_data_dir.clone(); + let logger = Arc::clone(&static_state.logger); + let bitcoin_network = static_state.network; + let network: Network = bitcoin_network.into(); + let ldk_peer_listening_port = static_state.ldk_peer_listening_port; + #[cfg(feature = "vss")] + let monitor_kv_store = setup.monitor_kv_store.clone().ok_or_else(|| { + APIError::Unexpected("Lightning monitor store is not available".to_string()) + })?; + #[cfg(feature = "vss")] + let bp_local_kv_store = Arc::clone(&setup.bp_local_kv_store); + #[cfg(feature = "vss")] + let vss_restored_keys = setup.vss_restored_keys; + #[cfg(feature = "vss")] + let bp_kv_store: BpKvStore = Arc::new(crate::async_kv_store::BpKvStoreRouter::new( + Arc::clone(&monitor_kv_store), + bp_local_kv_store, + Arc::clone(&kv_store), + )); + #[cfg(not(feature = "vss"))] + let bp_kv_store: BpKvStore = KVStoreSyncWrapper(Arc::clone(&kv_store)); + + // Regularly broadcast our node_announcement. This is only required (or possible) if we have + // some public channels. + let mut ldk_announced_listen_addr = Vec::new(); + for addr in &unlock_request.announce_addresses { + match SocketAddress::from_str(addr) { + Ok(sa) => { + ldk_announced_listen_addr.push(sa); + } + Err(_) => { + return Err(APIError::InvalidAnnounceAddresses(format!( + "failed to parse address '{addr}'" + ))) + } + } + } + let ldk_announced_node_name = match &unlock_request.announce_alias { + Some(s) => { + if s.len() > 32 { + return Err(APIError::InvalidAnnounceAlias(s!( + "cannot be longer than 32 bytes" + ))); + } + let mut bytes = [0; 32]; + bytes[..s.len()].copy_from_slice(s.as_bytes()); + bytes + } + None => [0; 32], + }; + + let listener = crate::utils::bind_first_available(&[ + format!("[::]:{ldk_peer_listening_port}"), + format!("0.0.0.0:{ldk_peer_listening_port}"), + ]) + .await + .map_err(|e| APIError::Unexpected(format!("failed to bind Lightning peer listener: {e}")))?; + // Initialize the chain backend for the requested sync mode let handle = tokio::runtime::Handle::current(); let ChainSetup { @@ -5072,45 +5559,10 @@ pub(crate) async fn start_ldk( } }; - // LDK signing: internal mode uses `KeysManager` from the mnemonic-derived LDK seed (BIP32 child - // 535 of the master xpriv). External mode uses `ExternalSigner` only; inbound / peer_storage / - // receive_auth key material comes from bootstrap hex fields (see `ExternalSigner::from_attachment`). + let keys_manager = setup.create_signer(); let cur = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap(); - - let keys_manager: ActiveSignerRef = if let Some(s) = external_signer.as_ref() { - Arc::new(DynRlnSigner::from_external(Arc::clone(s))) - } else { - let mnemonic = internal_mnemonic - .as_ref() - .expect("internal mnemonic must be present when external signer is not configured"); - let ldk_seed: [u8; 32] = { - let xkey: ExtendedKey = mnemonic - .clone() - .into_extended_key() - .expect("a valid key should have been provided"); - let master_xprv = &xkey - .into_xprv(network.into()) - .expect("should be possible to get an extended private key"); - let xprv: Xpriv = master_xprv - .derive_priv(&Secp256k1_30::new(), &ChildNumber::Hardened { index: 535 }) - .unwrap(); - xprv.private_key.secret_bytes() - }; - let internal_keys_manager = Arc::new(KeysManager::new( - &ldk_seed, - cur.as_secs(), - cur.subsec_nanos(), - true, - ldk_data_dir_path.clone(), - Arc::clone(&kv_store) as Arc, - )); - Arc::new(DynRlnSigner::from_internal(internal_keys_manager)) - }; - // `entropy_source` (app APIs) and `ldk_entropy_source` (LDK wiring) always use OsRng. - // When LDK passes `keys_manager` as `EntropySource`, external `DynRlnSigner` delegates to - // `ExternalSigner` which uses the same system RNG — never the host `GetSecureRandomBytes` RPC. let entropy_source: Arc = Arc::new(SystemEntropySource); let ldk_entropy_source = Arc::new(LightningEntropySource::new(Arc::clone(&entropy_source))); @@ -5352,187 +5804,10 @@ pub(crate) async fn start_ldk( } } - // Prepare the RGB wallet - let (account_xpub_vanilla, account_xpub_colored, master_fingerprint, rgb_wallet_mnemonic) = - if external_signer_mode { - let bootstrap = external_bootstrap.clone().ok_or_else(|| { - APIError::ExternalSignerProtocolError( - "missing external bootstrap in external mode".to_string(), - ) - })?; - ( - bootstrap.identity.account_xpub_vanilla, - bootstrap.identity.account_xpub_colored, - bootstrap.identity.master_fingerprint, - None, - ) - } else { - let mnemonic_str = internal_mnemonic - .as_ref() - .ok_or_else(|| { - APIError::ExternalSignerProtocolError( - "missing internal mnemonic in internal mode".to_string(), - ) - })? - .to_string(); - let (_, account_xpub_vanilla, _) = get_account_data( - &bitcoin_network, - &mnemonic_str, - false, - WitnessVersion::Taproot, - ) - .unwrap(); - let (_, account_xpub_colored, master_fingerprint) = get_account_data( - &bitcoin_network, - &mnemonic_str, - true, - WitnessVersion::Taproot, - ) - .unwrap(); - ( - account_xpub_vanilla.to_string(), - account_xpub_colored.to_string(), - master_fingerprint.to_string(), - Some(mnemonic_str.clone()), - ) - }; - let data_dir = static_state - .storage_dir_path - .clone() - .to_string_lossy() - .to_string(); - - // Pull the RGB wallet down from VSS before constructing it locally, when - // VSS is configured and the local wallet directory for this mnemonic's - // fingerprint is absent. Mirrors the KV-side auto-restore at the top of - // this function — together they make `unlock` recover the full node - // state (channels + assets + on-chain) on a fresh device. - #[cfg(feature = "vss")] - if let (Some(ref vss_url), Some(ref identity)) = (&static_state.vss_url, &vss_identity) { - let rgb_store_id = format!("{}_rgb", identity.pubkey_hex); - maybe_restore_rgb_from_vss( - vss_url, - rgb_store_id, - identity.signing_key, - &static_state.storage_dir_path, - &master_fingerprint.to_string(), - static_state.vss_allow_empty_restore, - ) + let common = setup + .create_common_state(keys_manager.clone(), entropy_source) .await?; - } - - let keys = SinglesigKeys { - account_xpub_vanilla: account_xpub_vanilla.clone(), - account_xpub_colored: account_xpub_colored.clone(), - vanilla_keychain: None, - master_fingerprint: master_fingerprint.clone(), - mnemonic: rgb_wallet_mnemonic, - witness_version: WitnessVersion::Taproot, - }; - let reuse_addresses = static_state.reuse_addresses; - let indexer_url_owned = indexer_url.to_string(); - let eth_rpc_url = unlock_request - .eth_rpc_url - .clone() - .or_else(|| static_state.config.chain.eth_rpc_url.clone()); - #[cfg(feature = "vss")] - let rgb_vss_backup = match (&static_state.vss_url, &vss_identity) { - (Some(vss_url), Some(identity)) => Some(( - vss_url.clone(), - format!("{}_rgb", identity.pubkey_hex), - identity.signing_key, - )), - _ => None, - }; - // go_online and configure_vss_backup drive blocking rgb-lib HTTP clients; - // run them off the async runtime so they don't fail on a single-vCPU host. - let (rgb_wallet, rgb_online) = tokio::task::spawn_blocking(move || { - let mut rgb_wallet = RgbLibWallet::new( - WalletData { - data_dir, - bitcoin_network, - database_type: DatabaseType::Sqlite, - max_allocations_per_utxo: 1, - supported_schemas: supported_asset_schemas(bitcoin_network, eth_rpc_url.is_some()), - reuse_addresses, - }, - keys, - ) - .expect("valid rgb-lib wallet"); - let rgb_online = rgb_wallet.go_online(OnlineOptions { - indexer_url: indexer_url_owned, - skip_consistency_check: false, - vanilla_sync_lookback: 20, - eth_rpc_url, - })?; - #[cfg(feature = "vss")] - if let Some((vss_url, rgb_store_id, signing_key)) = rgb_vss_backup { - let vss_config = - rgb_lib::wallet::vss::VssBackupConfig::new(vss_url, rgb_store_id, signing_key) - .with_encryption(true) - .with_auto_backup(true) - .with_backup_mode(rgb_lib::wallet::vss::VssBackupMode::Blocking); - // Fail closed: a misconfigured backup must not silently run local-only. - rgb_wallet.configure_vss_backup(vss_config).map_err(|e| { - APIError::FailedVssInit(format!( - "Failed to configure VSS backup for RGB wallet: {e}" - )) - })?; - tracing::info!("VSS auto-backup (blocking) enabled for RGB wallet"); - // Auto-backup only tracks local changes; an empty remote (fresh - // wallet or wiped store) needs an explicit upload. - if let Some(client) = rgb_wallet.vss_client() { - let rt = client.handle().clone(); - let info = rt - .block_on(rgb_wallet.vss_backup_info(&client)) - .map_err(|e| APIError::FailedVssInit(format!("VSS backup info: {e}")))?; - if !info.backup_exists { - rt.block_on(rgb_wallet.vss_backup(&client)).map_err(|e| { - APIError::FailedVssInit(format!("initial RGB VSS backup failed: {e}")) - })?; - tracing::info!("Uploaded RGB wallet backup to empty VSS store"); - } - } - } - Ok::<_, APIError>((rgb_wallet, rgb_online)) - }) - .await - .map_err(|e| APIError::Unexpected(format!("rgb-lib wallet setup task failed: {e}")))??; - save_config( - &static_state.db(), - kv_store.as_ref(), - CONFIG_WALLET_FINGERPRINT, - &master_fingerprint, - )?; - save_config( - &static_state.db(), - kv_store.as_ref(), - CONFIG_WALLET_ACCOUNT_XPUB_COLORED, - &account_xpub_colored, - )?; - save_config( - &static_state.db(), - kv_store.as_ref(), - CONFIG_WALLET_ACCOUNT_XPUB_VANILLA, - &account_xpub_vanilla, - )?; - save_config( - &static_state.db(), - kv_store.as_ref(), - CONFIG_WALLET_MASTER_FINGERPRINT, - &master_fingerprint, - )?; - - // No second VssBackupClient is constructed here: the manual /vssbackup - // and /vssbackupinfo routes use the wallet's own client, retrievable via - // `wallet.vss_client()` (R-lib.1 in rgb-lib's PR #31). Keeping a single - // client per stream avoids running two tokio runtimes for the same - // backups and removes the race between the two clients writing - // overlapping state. - let rgb_wallet_wrapper = Arc::new(RgbLibWalletWrapper::new( - Arc::new(Mutex::new(rgb_wallet)), - rgb_online, - )); + let rgb_wallet_wrapper = Arc::clone(&common.rgb_wallet_wrapper); reimport_funding_consignments(&rgb_wallet_wrapper, &kv_store, &ldk_data_dir).await; @@ -5845,36 +6120,42 @@ pub(crate) async fn start_ldk( // ## Running LDK // Initialize networking + let output_sweeper: Arc = Arc::new(output_sweeper); + // Finish fallible initial sync before any protocol worker starts accepting peers. + #[cfg(feature = "transaction-sync")] + #[allow(irrefutable_let_patterns)] + if let ChainBackend::TransactionSync { tx_sync, .. } = &backend { + let confirmables: Vec> = vec![ + channel_manager.clone(), + chain_monitor.clone(), + output_sweeper.clone(), + ]; + sync_chain_data(tx_sync.clone(), confirmables) + .await + .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; + } let peer_manager_connection_handler = peer_manager.clone(); - let listening_port = ldk_peer_listening_port; let stop_processing = Arc::new(AtomicBool::new(false)); - let stop_listen = Arc::clone(&stop_processing); - tokio::spawn(async move { - // Dual-stack when available; hosts with IPv6 disabled fall back to IPv4. - let listener = crate::utils::bind_first_available(&[ - format!("[::]:{listening_port}"), - format!("0.0.0.0:{listening_port}"), - ]) - .await - .expect("Failed to bind to listen port - is something else already listening on it?"); + let shutdown = CancellationToken::new(); + let listener_shutdown = shutdown.clone(); + let listener_task = tokio::spawn(async move { loop { + let accepted = tokio::select! { + biased; + _ = listener_shutdown.cancelled() => break, + accepted = listener.accept() => accepted, + }; + let tcp_stream = accepted + .expect("Lightning listener failed to accept a connection") + .0; let peer_mgr = peer_manager_connection_handler.clone(); - let tcp_stream = listener.accept().await.unwrap().0; - if stop_listen.load(Ordering::Acquire) { - return; - } tokio::spawn(async move { - lightning_net_tokio::setup_inbound( - peer_mgr.clone(), - tcp_stream.into_std().unwrap(), - ) - .await; + lightning_net_tokio::setup_inbound(peer_mgr, tcp_stream.into_std().unwrap()).await; }); } }); // Connect and Disconnect Blocks - let output_sweeper: Arc = Arc::new(output_sweeper); let stop_listen = Arc::clone(&stop_processing); match backend { #[cfg(feature = "block-sync")] @@ -5911,10 +6192,6 @@ pub(crate) async fn start_ldk( chain_monitor.clone(), output_sweeper.clone(), ]; - // bring everything up to the current tip before starting to serve - sync_chain_data(tx_sync.clone(), confirmables.clone()) - .await - .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; tokio::spawn(async move { loop { if stop_listen.load(Ordering::Acquire) { @@ -6122,13 +6399,11 @@ pub(crate) async fn start_ldk( external_signer: external_signer.clone(), })); - let unlocked_state = Arc::new(UnlockedAppState { - config: static_state.config.clone(), + let unlocked_state = Arc::new(LightningState { + common: Arc::clone(&common), channel_manager: Arc::clone(&channel_manager), gossip_source: Arc::clone(&gossip_source), inbound_payments, - signer: keys_manager, - entropy_source, network_graph, chain_monitor: chain_monitor.clone(), onion_messenger: onion_messenger.clone(), @@ -6137,21 +6412,15 @@ pub(crate) async fn start_ldk( async_order_handler, asset_link_handler: Arc::clone(&asset_link_handler), async_payments_preimage_root, - kv_store: Arc::clone(&kv_store), #[cfg(feature = "vss")] monitor_kv_store: Arc::clone(&monitor_kv_store), rgb_file_transfer_handler: Arc::clone(&rgb_file_transfer_handler), bump_tx_event_handler, - rgb_wallet_wrapper, maker_swaps, taker_swaps: Arc::clone(&taker_swaps), router: Arc::clone(&router), output_sweeper: Arc::clone(&output_sweeper), channel_ids_map, - proxy_endpoint: proxy_endpoint.to_string(), - external_signer_mode, - external_signer, - external_node_id, virtual_channel_draft_store, virtual_channel_session_store, next_payment_idx, @@ -6235,28 +6504,6 @@ pub(crate) async fn start_ldk( app_state.cancel_token.clone(), )); - // Periodically drain queued VSS replications so an idle node still heals - // after an outage (drains are otherwise only triggered by new writes). - #[cfg(feature = "vss")] - { - let drain_store = Arc::clone(&kv_store); - let stop_drain = Arc::clone(&stop_processing); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(60)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - loop { - interval.tick().await; - if stop_drain.load(Ordering::Acquire) { - break; - } - let store = Arc::clone(&drain_store); - if let Err(e) = tokio::task::spawn_blocking(move || store.drain_pending()).await { - tracing::error!(error = %e, "periodic VSS drain task failed"); - } - } - }); - } - // Regularly reconnect to channel peers. let connect_cm = Arc::clone(&channel_manager); let connect_pm = Arc::clone(&peer_manager); @@ -6369,35 +6616,6 @@ pub(crate) async fn start_ldk( }); } - // Regularly broadcast our node_announcement. This is only required (or possible) if we have - // some public channels. - let mut ldk_announced_listen_addr = Vec::new(); - for addr in unlock_request.announce_addresses { - match SocketAddress::from_str(&addr) { - Ok(sa) => { - ldk_announced_listen_addr.push(sa); - } - Err(_) => { - return Err(APIError::InvalidAnnounceAddresses(format!( - "failed to parse address '{addr}'" - ))) - } - } - } - let ldk_announced_node_name = match unlock_request.announce_alias { - Some(s) => { - if s.len() > 32 { - return Err(APIError::InvalidAnnounceAlias(s!( - "cannot be longer than 32 bytes" - ))); - } - let mut bytes = [0; 32]; - bytes[..s.len()].copy_from_slice(s.as_bytes()); - bytes - } - None => [0; 32], - }; - // cleanup the buffers of RGB file transfers a peer started and never finished let sweep_handler = Arc::clone(&rgb_file_transfer_handler); let stop_sweep = Arc::clone(&stop_processing); @@ -6415,17 +6633,24 @@ pub(crate) async fn start_ldk( let peer_man = Arc::clone(&peer_manager); let chan_man = Arc::clone(&channel_manager); + let announcement_shutdown = shutdown.clone(); let announce_initial_delay_secs = static_state.config.node.announce_initial_delay_secs; let announce_refresh_interval_secs = static_state.config.node.announce_refresh_interval_secs; - tokio::spawn(async move { + let announcement_task = tokio::spawn(async move { // First wait until we have some peers and maybe have opened a channel. - tokio::time::sleep(Duration::from_secs(announce_initial_delay_secs)).await; + tokio::select! { + _ = announcement_shutdown.cancelled() => return, + _ = tokio::time::sleep(Duration::from_secs(announce_initial_delay_secs)) => {} + } // Then, update our announcement periodically to keep it fresh but avoid unnecessary churn // in the global gossip network. let mut interval = tokio::time::interval(Duration::from_secs(announce_refresh_interval_secs)); loop { - interval.tick().await; + tokio::select! { + _ = announcement_shutdown.cancelled() => break, + _ = interval.tick() => {} + } // Don't bother trying to announce if we don't have any public channls, though our // peers should drop such an announcement anyway. Note that announcement may not // propagate until we have a channel with 6+ confirmations. @@ -6447,19 +6672,24 @@ pub(crate) async fn start_ldk( tracing::info!("Local Node ID is {}", channel_manager.get_our_node_id()); #[cfg(feature = "vss")] - if let Some(guard) = fence_guard.as_mut() { + if let Some(guard) = setup.fence_guard.as_mut() { guard.disarm(); } Ok(( - LdkBackgroundServices { + Some(LdkBackgroundServices { stop_processing, gossip_shutdown, peer_manager: peer_manager.clone(), bp_exit, background_processor: Some(background_processor), - }, - unlocked_state, + shutdown, + tasks: vec![listener_task, announcement_task], + }), + Arc::new(UnlockedAppState { + common, + lightning: Some(unlocked_state), + }), )) } @@ -6484,22 +6714,17 @@ pub(crate) fn attach_external_signer_transport( } impl AppState { - fn stop_ldk(&self) -> Option>> { + fn stop_lightning(&self) -> Option { let mut ldk_background_services = self.get_ldk_background_services(); - if ldk_background_services.is_none() { - // node is locked - tracing::info!("LDK is not running"); - return None; - } - - let ldk_background_services = ldk_background_services.as_mut().unwrap(); + let ldk_background_services = ldk_background_services.take()?; // Disconnect our peers and stop accepting new connections. This ensures we don't continue // updating our channel data after we've stopped the background processor. ldk_background_services .stop_processing .store(true, Ordering::Release); + ldk_background_services.shutdown.cancel(); ldk_background_services.gossip_shutdown.notify_one(); ldk_background_services.peer_manager.disconnect_all_peers(); @@ -6508,10 +6733,8 @@ impl AppState { // already gone. Also, send can find no receiver during a panic (racy). if !ldk_background_services.bp_exit.is_closed() { let _ = ldk_background_services.bp_exit.send(()); - ldk_background_services.background_processor.take() - } else { - None } + Some(ldk_background_services) } } @@ -6547,10 +6770,15 @@ enum VssTeardown { #[cfg(feature = "vss")] async fn stop_vss_stores( kv_store: &Arc, - monitor_kv_store: &Arc, + monitor_kv_store: Option<&Arc>, deadline: Instant, ) -> VssTeardown { let remaining = || deadline.saturating_duration_since(Instant::now()); + let stop_monitors = || { + if let Some(store) = monitor_kv_store { + store.stop(); + } + }; let flush_store = Arc::clone(kv_store); let flush_deadline = std::cmp::min(deadline, Instant::now() + VSS_TEARDOWN_FLUSH_WINDOW); @@ -6565,12 +6793,12 @@ async fn stop_vss_stores( ), Ok(Err(e)) => { tracing::error!(error = %e, "pending-queue flush task failed"); - monitor_kv_store.stop(); + stop_monitors(); return VssTeardown::Abandoned; } Err(_) => { tracing::error!("pending-queue flush did not finish within the teardown budget"); - monitor_kv_store.stop(); + stop_monitors(); return VssTeardown::Abandoned; } } @@ -6583,18 +6811,18 @@ async fn stop_vss_stores( Ok(Ok(())) => {} Ok(Err(e)) => { tracing::error!(error = %e, "pending-queue stop task failed"); - monitor_kv_store.stop(); + stop_monitors(); return VssTeardown::Abandoned; } Err(_) => { tracing::error!("pending-queue stop did not finish within the teardown budget"); - monitor_kv_store.stop(); + stop_monitors(); return VssTeardown::Abandoned; } } // Only signals the retry loops to abort, so it cannot block. Idempotent: the abandoned // paths above may have already called it. - monitor_kv_store.stop(); + stop_monitors(); VssTeardown::Complete } @@ -6657,7 +6885,7 @@ mod vss_teardown_tests { let teardown = stop_vss_stores( &kv_store, - &monitor_kv_store, + Some(&monitor_kv_store), Instant::now() + Duration::from_secs(5), ) .await; @@ -6666,6 +6894,15 @@ mod vss_teardown_tests { assert!(release_vss_fence(kv_store, teardown).await); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn wallet_only_teardown_does_not_require_monitor_store() { + let (kv_store, _) = local_stores(); + let teardown = + stop_vss_stores(&kv_store, None, Instant::now() + Duration::from_secs(5)).await; + assert_eq!(teardown, VssTeardown::Complete); + assert!(release_vss_fence(kv_store, teardown).await); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abandoned_teardown_keeps_the_fence() { let (kv_store, monitor_kv_store) = local_stores(); @@ -6676,7 +6913,7 @@ mod vss_teardown_tests { let teardown = stop_vss_stores( &kv_store, - &monitor_kv_store, + Some(&monitor_kv_store), Instant::now() + Duration::from_millis(100), ) .await; @@ -6688,76 +6925,87 @@ mod vss_teardown_tests { } } -pub(crate) async fn stop_ldk(app_state: Arc) { - tracing::info!("Stopping LDK"); +/// Stop the resources owned by the unlocked session, including wallet-only persistence. +pub(crate) async fn stop_node(app_state: Arc) { + tracing::info!("Stopping node services"); + // Wait for wallet operations holding the API mutex, then remove both session owners together. + // New callers cannot use a wallet after its VSS fence has been handed over; concurrent stops + // cannot release the fence while another stop is still flushing the background processor. + let mut unlocked_guard = app_state.get_unlocked_app_state().await; + let unlocked = unlocked_guard.take(); + let mut lightning = app_state.stop_lightning(); + drop(unlocked_guard); #[cfg(feature = "vss")] - let stores = app_state - .get_unlocked_app_state() - .await - .as_ref() - .map(|unlocked| { - ( - Arc::clone(&unlocked.kv_store), - Arc::clone(&unlocked.monitor_kv_store), - ) - }); + let common_and_monitor = unlocked.as_ref().map(|unlocked| { + unlocked.common.persistence_shutdown.cancel(); + ( + Arc::clone(&unlocked.common), + unlocked + .lightning + .as_ref() + .map(|lightning| Arc::clone(&lightning.monitor_kv_store)), + ) + }); #[cfg(feature = "vss")] - if let Some(mut join_handle) = app_state.stop_ldk() { - // Bounded flush: give the final remote-first persists time to reach - // VSS, then abort outage-pending retries so shutdown cannot hang. + if let Some(mut join_handle) = lightning + .as_mut() + .and_then(|services| services.background_processor.take()) + { match tokio::time::timeout(BP_SHUTDOWN_FLUSH_TIMEOUT, &mut join_handle).await { Ok(res) => log_bp_shutdown_result(res), Err(_) => { - tracing::error!( - "final VSS flush did not complete in {:?}; aborting pending \ - retries — last channel-manager state may not have replicated", - BP_SHUTDOWN_FLUSH_TIMEOUT - ); - if let Some((_, ref monitor_kv_store)) = stores { - monitor_kv_store.stop(); + tracing::error!("final VSS flush did not complete in {:?}; aborting pending retries — last channel-manager state may not have replicated", BP_SHUTDOWN_FLUSH_TIMEOUT); + if let Some((_, Some(ref monitor_store))) = common_and_monitor { + monitor_store.stop(); } log_bp_shutdown_result(join_handle.await); } } } #[cfg(not(feature = "vss"))] - if let Some(join_handle) = app_state.stop_ldk() { + if let Some(join_handle) = lightning + .as_mut() + .and_then(|services| services.background_processor.take()) + { log_bp_shutdown_result(join_handle.await); } - // Any shutdown that reaches here (lock, /shutdown, signal, fatal panic) hands the VSS fence - // over so the next unlock — a fresh instance id — takes over without an explicit - // /vssclearfence. The teardown is bounded, and the fence is only released once it provably - // completed; a hard kill, or a teardown abandoned at its deadline, leaves the fence behind. #[cfg(feature = "vss")] - { - if let Some((kv_store, monitor_kv_store)) = stores { - let deadline = Instant::now() + VSS_TEARDOWN_TIMEOUT; - let teardown = stop_vss_stores(&kv_store, &monitor_kv_store, deadline).await; - release_vss_fence(kv_store, teardown).await; + if let Some((common, monitor_store)) = common_and_monitor { + let deadline = Instant::now() + VSS_TEARDOWN_TIMEOUT; + let teardown = stop_vss_stores(&common.kv_store, monitor_store.as_ref(), deadline).await; + let worker = common.persistence_worker.lock().unwrap().take(); + if let Some(mut worker) = worker { + if tokio::time::timeout( + deadline.saturating_duration_since(Instant::now()), + &mut worker, + ) + .await + .is_err() + { + worker.abort(); + tracing::warn!("common persistence worker did not exit within teardown budget"); + } } + release_vss_fence(Arc::clone(&common.kv_store), teardown).await; } - // connect to the peer port so it can be released - let peer_port = app_state.static_state.ldk_peer_listening_port; - let sock_addr = SocketAddr::from(([127, 0, 0, 1], peer_port)); - let _ = check_port_is_available(peer_port); - // check the peer port has been released - let t_0 = OffsetDateTime::now_utc(); - loop { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - if TcpListener::bind(sock_addr).is_ok() { - break; - } - if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 10.0 { - tracing::error!("LDK peer port {peer_port} was not released within 10s"); - break; + if let Some(services) = lightning { + for mut task in services.tasks { + match tokio::time::timeout(Duration::from_secs(5), &mut task).await { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::error!(%error, "Lightning task failed during shutdown"), + Err(_) => { + task.abort(); + tracing::warn!("Lightning task did not exit after cancellation"); + } + } } } - - tracing::info!("Stopped LDK"); + drop(unlocked); + tracing::info!("Stopped node services"); } pub(crate) fn write_rgb_payment_info_file( diff --git a/src/lib.rs b/src/lib.rs index 730a034e..85c53fad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,9 +42,15 @@ mod gossip; mod kv_store; mod ldk; mod ldk_chain_backend; +#[cfg(all(test, feature = "electrum"))] +mod mainnet_startup_tests; +mod mainnet_state; #[cfg(test)] mod mainnet_tests; +#[cfg(all(test, feature = "vss", feature = "electrum"))] +mod mainnet_vss_tests; mod node; +mod node_info; mod rgb; mod rgb_file_transfer; mod rgb_import; diff --git a/src/main.rs b/src/main.rs index 4c8f0ace..e06620a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,8 @@ mod gossip; mod kv_store; mod ldk; mod ldk_chain_backend; +mod mainnet_state; +mod node_info; mod rgb; mod rgb_file_transfer; mod rgb_import; @@ -76,7 +78,7 @@ use tracing_subscriber::{ use crate::args::UserArgs; use crate::auth::conditional_auth_middleware; use crate::error::AppError; -use crate::ldk::stop_ldk; +use crate::ldk::stop_node; use crate::rgb_file_transfer::MAX_CONSIGNMENT_SIZE; use crate::rgb_import::MAX_RGB_IMPORT_BODY_BYTES; #[cfg(feature = "remote-signer")] @@ -359,7 +361,7 @@ async fn shutdown_signal(app_state: Arc) { tracing::info!("Will shutdown after change state is complete"); tokio::time::sleep(Duration::from_millis(300)).await; } - stop_ldk(app_state.clone()).await; + stop_node(app_state.clone()).await; } // workaround for https://github.com/tokio-rs/tracing/issues/1372 diff --git a/src/mainnet_startup_tests.rs b/src/mainnet_startup_tests.rs new file mode 100644 index 00000000..1b60d396 --- /dev/null +++ b/src/mainnet_startup_tests.rs @@ -0,0 +1,647 @@ +//! Successful wallet startup against a local mainnet indexer, with unused Lightning endpoints. + +use crate::{ + args::UserArgs, + core_types::LdkChainSync, + error::APIError, + routes, sdk, + utils::{start_daemon, AppState}, +}; +use axum::{ + routing::{get, post}, + Json, Router, +}; +use bitcoin::consensus::encode::serialize_hex; +use rgb_lib::BitcoinNetwork; +use serde_json::{json, Value}; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpListener, + task::JoinHandle, +}; + +const PASSWORD: &str = "mainnet-test-password"; +const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +pub(crate) struct Indexer { + pub(crate) url: String, + requests: Arc>>, + unexpected: Arc>>, + task: JoinHandle<()>, +} +impl Drop for Indexer { + fn drop(&mut self) { + self.task.abort(); + } +} +impl Indexer { + pub(crate) async fn new(network: bitcoin::Network) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("tcp://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let unexpected = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&requests); + let errors = Arc::clone(&unexpected); + let task = tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + loop { + let (stream, _) = listener.accept().await.unwrap(); + let seen = Arc::clone(&seen); + let errors = Arc::clone(&errors); + connections.spawn(async move { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let request: Value = serde_json::from_str(&line).unwrap(); + let response = |request: &Value| { + let method = request["method"].as_str().unwrap(); + seen.lock().unwrap().push(method.into()); + let genesis = bitcoin::blockdata::constants::genesis_block(network); + let header = serialize_hex(&genesis.header); + let result = match method { + "server.version" => json!(["fixture", "1.4"]), + "server.features" => json!({"server_version": "fixture", "hosts": {}, "genesis_hash": genesis.block_hash().to_string(), "hash_function": "sha256", "protocol_min": "1.4", "protocol_max": "1.4"}), + "blockchain.block.header" => { assert_eq!(request["params"][0], 0); json!(header) }, + "blockchain.block.headers" => json!({"count": 1, "hex": header, "max": 2016}), + "blockchain.transaction.get" => if request["params"][1] == true { json!({"confirmations": 1}) } else { json!(serialize_hex(&genesis.txdata[0])) }, + "blockchain.headers.subscribe" => json!({"height": 0, "hex": header}), + "blockchain.scripthash.get_history" | "blockchain.scripthash.listunspent" => json!([]), + "blockchain.scripthash.subscribe" => Value::Null, + "blockchain.estimatefee" | "blockchain.relayfee" => json!(0.00001), + _ => { errors.lock().unwrap().push(method.into()); Value::Null }, + }; + json!({"jsonrpc": "2.0", "id": request["id"], "result": result}) + }; + let result = if let Some(batch) = request.as_array() { + Value::Array(batch.iter().map(response).collect()) + } else { response(&request) }; + if writer.write_all(format!("{result}\n").as_bytes()).await.is_err() { break; } + } + }); + } + }); + Self { + url, + requests, + unexpected, + task, + } + } +} + +struct Fixture { + state: Arc, + _directory: tempfile::TempDir, + indexer: Indexer, + proxy: String, + proxy_task: JoinHandle<()>, + peer: TcpListener, + backend: TcpListener, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.proxy_task.abort(); + } +} +impl Fixture { + async fn new() -> Self { + let fixture = Self::uninitialized().await; + sdk::init( + fixture.state.clone(), + PASSWORD.into(), + Some(MNEMONIC.into()), + ) + .await + .unwrap(); + fixture + } + async fn uninitialized() -> Self { + Self::uninitialized_for_network(BitcoinNetwork::Mainnet).await + } + async fn uninitialized_for_network(network: BitcoinNetwork) -> Self { + let directory = tempfile::tempdir().unwrap(); + let indexer = Indexer::new(network.into()).await; + let peer = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let backend = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = format!("rpc://{}/json-rpc", proxy_listener.local_addr().unwrap()); + let proxy_router = Router::new().route("/json-rpc", post(|Json(request): Json| async move { + assert_eq!(request["method"], "server.info"); + Json(json!({"jsonrpc": "2.0", "id": request["id"], "result": {"protocol_version": "0.2", "version": "fixture", "uptime": 1}})) + })); + let proxy_task = + tokio::spawn(async move { axum::serve(proxy_listener, proxy_router).await.unwrap() }); + let state = start_daemon(&UserArgs { + storage_dir_path: directory.path().to_path_buf(), + daemon_listening_port: 0, + ldk_peer_listening_port: if network == BitcoinNetwork::Mainnet { + peer.local_addr().unwrap().port() + } else { + 0 + }, + network, + max_media_upload_size_mb: 1, + max_aggregated_media_size_per_channel_mb: 1, + max_pending_consignments: 10, + max_media_files_per_channel: 10, + root_public_key: None, + enable_virtual_channels_v0: false, + virtual_peer_pubkeys: vec![], + lsp_base_url: None, + lsp_bearer_token: None, + vss_url: None, + vss_allow_empty_restore: false, + reuse_addresses: false, + remote_signer_listen_addr: None, + config: Default::default(), + }) + .await + .unwrap(); + Self { + state, + _directory: directory, + indexer, + proxy, + proxy_task, + peer, + backend, + } + } + + fn request(&self) -> sdk::UnlockRequest { + sdk::UnlockRequest { + password: PASSWORD.into(), + indexer_url: Some(self.indexer.url.clone()), + eth_rpc_url: None, + proxy_endpoint: Some(self.proxy.clone()), + announce_addresses: vec![], + announce_alias: None, + gossip_rgs_server_url: Some(format!("http://{}", self.backend.local_addr().unwrap())), + ldk_chain_sync: self.chain_sync(), + } + } + fn chain_sync(&self) -> LdkChainSync { + #[cfg(feature = "block-sync")] + { + LdkChainSync::BlockSync { + bitcoind_rpc_username: "unused".into(), + bitcoind_rpc_password: "unused".into(), + bitcoind_rpc_host: "127.0.0.1".into(), + bitcoind_rpc_port: self.backend.local_addr().unwrap().port(), + } + } + #[cfg(not(feature = "block-sync"))] + { + LdkChainSync::TransactionSync { + indexer_url: format!("tcp://{}", self.backend.local_addr().unwrap()), + } + } + } + async fn assert_no_lightning(&self) { + let state = self.state.unlocked_app_state.lock().await; + assert!(state.as_ref().unwrap().lightning.is_none()); + assert!(self.state.ldk_background_services.lock().unwrap().is_none()); + assert!( + tokio::time::timeout(Duration::from_millis(20), self.peer.accept()) + .await + .is_err() + ); + assert!( + tokio::time::timeout(Duration::from_millis(20), self.backend.accept()) + .await + .is_err() + ); + assert!( + self.indexer.unexpected.lock().unwrap().is_empty(), + "{:?}", + self.indexer.unexpected.lock().unwrap() + ); + assert!(!self + .indexer + .requests + .lock() + .unwrap() + .iter() + .any(|method| method == "blockchain.transaction.broadcast")); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_sdk_unlock_runs_wallet_without_lightning_and_restarts() { + let fixture = Fixture::new().await; + let mut wrong = fixture.request(); + wrong.password = "wrong-password".into(); + assert!(matches!( + sdk::unlock(fixture.state.clone(), wrong).await, + Err(APIError::WrongPassword) + )); + assert!(!*fixture.state.changing_state.lock().unwrap()); + sdk::unlock(fixture.state.clone(), fixture.request()) + .await + .unwrap(); + fixture.assert_no_lightning().await; + let info = sdk::node_info(fixture.state.clone()).await.unwrap(); + assert_eq!( + (info.num_channels, info.num_peers, info.local_balance_sat), + (0, 0, 0) + ); + assert_eq!( + ( + info.network_nodes, + info.network_channels, + info.latest_rgs_snapshot_timestamp + ), + (0, 0, None) + ); + let address = sdk::address(fixture.state.clone()).await.unwrap().address; + assert!(address.starts_with("bc1")); + let balance = sdk::btc_balance(fixture.state.clone(), false) + .await + .unwrap(); + assert_eq!((balance.vanilla.settled, balance.colored.settled), (0, 0)); + let assets = sdk::list_assets(fixture.state.clone(), vec![]) + .await + .unwrap(); + assert!(assets.nia.unwrap().is_empty()); + assert!(sdk::list_transactions(fixture.state.clone(), true, None) + .await + .unwrap() + .is_empty()); + assert!(sdk::list_unspents(fixture.state.clone(), true) + .await + .unwrap() + .is_empty()); + let invoice = sdk::rgb_invoice( + fixture.state.clone(), + sdk::RgbInvoiceRequestData { + asset_id: None, + assignment_kind: None, + assignment_amount: None, + duration_seconds: None, + min_confirmations: 1, + witness: true, + }, + ) + .await + .unwrap(); + let decoded = sdk::decode_rgb_invoice(fixture.state.clone(), invoice.invoice) + .await + .unwrap(); + assert_eq!(decoded.network, BitcoinNetwork::Mainnet); + let signature = sdk::sign_message(fixture.state.clone(), "mainnet wallet".into()) + .await + .unwrap(); + assert!(sdk::verify_message( + fixture.state.clone(), + "mainnet wallet".into(), + signature.signed_message + ) + .await + .unwrap()); + assert_eq!( + sdk::network_info(fixture.state.clone()) + .await + .unwrap() + .height, + 0 + ); + assert!(matches!( + sdk::list_peers(fixture.state.clone()).await, + Err(APIError::LightningUnsupportedOnMainnet) + )); + fixture.assert_no_lightning().await; + let _ = routes::lock(axum::extract::State(fixture.state.clone())) + .await + .unwrap(); + assert!(matches!( + routes::lock(axum::extract::State(fixture.state.clone())).await, + Err(APIError::LockedNode) + )); + sdk::unlock(fixture.state.clone(), fixture.request()) + .await + .unwrap(); + assert_eq!( + sdk::node_info(fixture.state.clone()).await.unwrap().pubkey, + info.pubkey + ); + fixture.assert_no_lightning().await; + let _ = routes::lock(axum::extract::State(fixture.state.clone())) + .await + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(20), fixture.peer.accept()) + .await + .is_err() + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_rest_unlock_rejects_wrong_indexer_then_serves_wallet() { + let fixture = Fixture::new().await; + let wrong_indexer = Indexer::new(bitcoin::Network::Regtest).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let router = Router::new() + .route("/unlock", post(routes::unlock)) + .route("/lock", post(routes::lock)) + .route("/address", post(routes::address)) + .route("/nodeinfo", get(routes::node_info)) + .route("/networkinfo", get(routes::network_info)) + .with_state(fixture.state.clone()); + let task = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + let mut body = json!({"password": PASSWORD, "ldk_chain_sync": fixture.chain_sync(), "indexer_url": wrong_indexer.url, "proxy_endpoint": fixture.proxy, "announce_addresses": []}); + let response = client + .post(format!("{base}/unlock")) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); + assert_eq!( + response.json::().await.unwrap()["name"], + "InvalidIndexer" + ); + assert!(fixture.state.unlocked_app_state.lock().await.is_none()); + assert!(!*fixture.state.changing_state.lock().unwrap()); + body["indexer_url"] = json!(fixture.indexer.url); + let response = client + .post(format!("{base}/unlock")) + .json(&body) + .send() + .await + .unwrap(); + let status = response.status(); + let result = response.text().await.unwrap(); + assert_eq!(status, reqwest::StatusCode::OK, "{result}"); + fixture.assert_no_lightning().await; + let address: Value = client + .post(format!("{base}/address")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(address["address"].as_str().unwrap().starts_with("bc1")); + let info: Value = client + .get(format!("{base}/nodeinfo")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(info["num_channels"], 0); + let info: Value = client + .get(format!("{base}/networkinfo")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(info["height"], 0); + assert_eq!( + client + .post(format!("{base}/lock")) + .send() + .await + .unwrap() + .status(), + reqwest::StatusCode::OK + ); + task.abort(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_legacy_state_refusal_preserves_bytes_and_does_not_contact_indexer() { + use lightning::util::persist::KVStoreSync; + let fixture = Fixture::new().await; + let store = crate::kv_store::SeaOrmKvStore::from_connection(fixture.state.db()); + store + .write("", "", "manager", b"opaque previous snapshot".to_vec()) + .unwrap(); + for _ in 0..2 { + let result = sdk::unlock(fixture.state.clone(), fixture.request()).await; + assert!(matches!(result, Err(APIError::MainnetLightningState(_)))); + assert!(fixture.state.unlocked_app_state.lock().await.is_none()); + assert!(!*fixture.state.changing_state.lock().unwrap()); + assert_eq!( + store.read("", "", "manager").unwrap(), + b"opaque previous snapshot" + ); + assert!(fixture.indexer.requests.lock().unwrap().is_empty()); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn canceled_sdk_caller_does_not_abandon_wallet_startup() { + let fixture = Fixture::new().await; + let state = fixture.state.clone(); + let request = fixture.request(); + let caller = tokio::spawn(async move { sdk::unlock(state, request).await }); + tokio::time::timeout(Duration::from_secs(10), async { + while fixture.indexer.requests.lock().unwrap().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + caller.abort(); + tokio::time::timeout(Duration::from_secs(10), async { + while *fixture.state.changing_state.lock().unwrap() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + fixture.assert_no_lightning().await; + let _ = routes::lock(axum::extract::State(fixture.state.clone())) + .await + .unwrap(); +} + +#[cfg(all(feature = "uniffi", feature = "vls"))] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_attached_and_native_signer_unlock_keep_strict_policy_without_channel_calls() { + use crate::uniffi_api::{ + ExternalSignerHost, NativeExternalSigner, RlnError, SdkLdkChainSync, SdkNode, + }; + struct Host(Arc); + impl ExternalSignerHost for Host { + fn call(&self, bytes: Vec) -> Result, RlnError> { + let request = crate::signer::proto::decode_signer_request(&bytes).unwrap(); + assert!( + !matches!( + request, + crate::signer::types::ExternalSignerRequest::Channel(_) + ), + "unexpected channel signing request" + ); + self.0.call(bytes) + } + } + let fixture = Fixture::uninitialized().await; + let signer = NativeExternalSigner::new("42".repeat(32), "mainnet".into(), Some(false)).unwrap(); + let node = SdkNode { + handle: crate::NodeHandle::from_app_state(fixture.state.clone()), + }; + node.init_with_native_external_signer(signer.clone()) + .unwrap(); + node.attach_external_signer(Arc::new(Host(signer.clone())), signer.bootstrap().unwrap()) + .unwrap(); + sdk::unlock_with_attached_external_signer(fixture.state.clone(), fixture.request()) + .await + .unwrap(); + fixture.assert_no_lightning().await; + assert_eq!( + node.node_info().unwrap().pubkey.to_string(), + signer.bootstrap().unwrap().node_id + ); + assert!(node.address().unwrap().address.starts_with("bc1")); + let _ = routes::lock(axum::extract::State(fixture.state.clone())) + .await + .unwrap(); + let backend = fixture.backend.local_addr().unwrap(); + let chain = SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "unused".into(), + bitcoind_rpc_password: "unused".into(), + bitcoind_rpc_host: "127.0.0.1".into(), + bitcoind_rpc_port: backend.port(), + }; + node.unlock_with_native_external_signer( + signer, + chain, + Some(fixture.indexer.url.clone()), + Some(fixture.proxy.clone()), + vec![], + None, + ) + .unwrap(); + fixture.assert_no_lightning().await; + node.shutdown(); + assert!(fixture.state.unlocked_app_state.lock().await.is_none()); + assert!(!*fixture.state.changing_state.lock().unwrap()); +} + +/// Exercises real LDK construction and an empty persisted-manager restart without external services. +/// Funded channels, payments, and chain advancement still require the regtest integration suite. +#[cfg(feature = "transaction-sync")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn regtest_starts_lightning_and_restores_after_lock() { + use lightning::util::persist::{ + KVStoreSync, CHANNEL_MANAGER_PERSISTENCE_KEY, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + }; + + let fixture = Fixture::uninitialized_for_network(BitcoinNetwork::Regtest).await; + sdk::init( + fixture.state.clone(), + PASSWORD.into(), + Some(MNEMONIC.into()), + ) + .await + .unwrap(); + let request = || { + let mut request = fixture.request(); + request.gossip_rgs_server_url = None; + request.ldk_chain_sync = LdkChainSync::TransactionSync { + indexer_url: fixture.indexer.url.clone(), + }; + request + }; + let mut node_id = None; + for _ in 0..2 { + tokio::time::timeout( + Duration::from_secs(30), + sdk::unlock(fixture.state.clone(), request()), + ) + .await + .expect("regtest startup timed out") + .unwrap(); + assert!(fixture + .state + .unlocked_app_state + .lock() + .await + .as_ref() + .unwrap() + .lightning + .is_some()); + assert!(fixture + .state + .ldk_background_services + .lock() + .unwrap() + .is_some()); + let info = sdk::node_info(fixture.state.clone()).await.unwrap(); + if let Some(expected) = &node_id { + assert_eq!(&info.pubkey, expected); + } else { + node_id = Some(info.pubkey); + } + assert!(sdk::list_peers(fixture.state.clone()) + .await + .unwrap() + .is_empty()); + assert!(sdk::list_channels(fixture.state.clone()) + .await + .unwrap() + .is_empty()); + assert!(sdk::list_payments(fixture.state.clone()) + .await + .unwrap() + .is_empty()); + assert!(sdk::address(fixture.state.clone()) + .await + .unwrap() + .address + .starts_with("bcrt1")); + let _ = tokio::time::timeout( + Duration::from_secs(30), + routes::lock(axum::extract::State(fixture.state.clone())), + ) + .await + .expect("regtest shutdown timed out") + .unwrap(); + assert!(fixture.state.unlocked_app_state.lock().await.is_none()); + assert!(fixture + .state + .ldk_background_services + .lock() + .unwrap() + .is_none()); + let store = crate::kv_store::SeaOrmKvStore::from_connection(fixture.state.db()); + assert!(!store + .read( + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + ) + .unwrap() + .is_empty()); + } + assert!( + fixture.indexer.unexpected.lock().unwrap().is_empty(), + "{:?}", + fixture.indexer.unexpected.lock().unwrap() + ); + assert!(!fixture + .indexer + .requests + .lock() + .unwrap() + .iter() + .any(|method| method == "blockchain.transaction.broadcast")); +} diff --git a/src/mainnet_state.rs b/src/mainnet_state.rs new file mode 100644 index 00000000..98522e30 --- /dev/null +++ b/src/mainnet_state.rs @@ -0,0 +1,447 @@ +//! Read-only preflight before starting a mainnet wallet without Lightning. +//! +//! LDK does not expose a side-effect-free inspector for its persisted manager or sweeper. +//! Their presence is therefore ambiguous, including snapshots from an old empty node. Do not +//! deserialize them, infer safety from absent monitors, or delete them to make unlock succeed. + +use std::path::Path; + +use lightning::rgb_utils::{RGB_PRIMARY_NS, RGB_WALLET_CONFIG_NS}; +use sea_orm::{DatabaseConnection, EntityTrait, QuerySelect}; + +use crate::database::entities::{ChannelPeerEntity, KvStoreColumn, KvStoreEntity}; +use crate::error::APIError; + +// These are the existing, reconstructible mirrors written by ldk::save_config. This is an +// exact allowlist, not permission to replay arbitrary data under the wallet_config namespace. +const COMMON_CONFIG_KEYS: [&str; 6] = [ + "indexer_url", + "bitcoin_network", + "wallet_fingerprint", + "wallet_account_xpub_vanilla", + "wallet_account_xpub_colored", + "wallet_master_fingerprint", +]; + +// Keep recognizing this persisted namespace even in a build without the vss feature. +const PENDING_NAMESPACE: &str = "vss_pending"; + +fn needs_review(detail: impl Into) -> APIError { + APIError::MainnetLightningState(detail.into()) +} + +fn is_common_config(primary: &str, secondary: &str, key: &str) -> bool { + primary == RGB_PRIMARY_NS + && secondary == RGB_WALLET_CONFIG_NS + && COMMON_CONFIG_KEYS.contains(&key) +} + +fn is_common_remote_key(key: &str) -> bool { + COMMON_CONFIG_KEYS + .iter() + .any(|name| key == format!("{RGB_PRIMARY_NS}/{RGB_WALLET_CONFIG_NS}/{name}")) +} + +/// Render only bounded key metadata, never a stored value or full filesystem path. +fn key_label(primary: &str, secondary: &str, key: &str) -> String { + fn bounded(value: &str) -> String { + let prefix: String = value.chars().take(64).collect(); + format!("{prefix:?}") + } + format!( + "{}/{}/{}", + bounded(primary), + bounded(secondary), + bounded(key) + ) +} + +fn check_pending_intent(key: &str, value: &[u8]) -> Result<(), APIError> { + // SyncedKvStore stores a one-byte tag followed by a put payload, or just tag 0 for a + // removal. It otherwise loads every intent and may drain it during an unrelated write. + let well_formed = match value.split_first() { + Some((0, payload)) => payload.is_empty(), + Some((1, payload)) => std::str::from_utf8(payload).is_ok(), + _ => false, + }; + if !is_common_remote_key(key) || !well_formed { + return Err(needs_review(format!( + "local pending replication intent {} cannot be replayed by a wallet-only node", + key_label(PENDING_NAMESPACE, "", key) + ))); + } + Ok(()) +} + +fn check_ldk_directory(ldk_data_dir: &Path) -> Result<(), APIError> { + let metadata = match std::fs::symlink_metadata(ldk_data_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => { + return Err(needs_review( + "local Lightning directory cannot be inspected", + )) + } + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(needs_review( + "local Lightning directory is not a regular directory", + )); + } + let entries = std::fs::read_dir(ldk_data_dir) + .map_err(|_| needs_review("local Lightning directory cannot be enumerated"))?; + for entry in entries { + let entry = + entry.map_err(|_| needs_review("local Lightning directory entry is unreadable"))?; + let file_type = entry + .file_type() + .map_err(|_| needs_review("local Lightning directory entry cannot be inspected"))?; + // The shared logger creates this directory even before the first wallet unlock. + if entry.file_name() == crate::utils::LOGS_DIR && file_type.is_dir() { + continue; + } + return Err(needs_review(format!( + "local Lightning directory contains an unclassified entry {}", + key_label("", "", &entry.file_name().to_string_lossy()) + ))); + } + Ok(()) +} + +#[cfg(feature = "vss")] +fn check_remote_keys(keys: &[String]) -> Result<(), APIError> { + for key in keys { + if !is_common_remote_key(key) { + return Err(needs_review(format!( + "remote Lightning store contains an unclassified key {}", + key_label("", "", key) + ))); + } + } + Ok(()) +} + +/// Caller must serialize startup, acquire any configured VSS fence first, and run this on a +/// blocking worker before constructing SyncedKvStore or writing configuration mirrors. This +/// deliberately refuses opaque legacy snapshots; it does not claim they contain live funds. +pub(crate) fn check_mainnet_legacy_state( + database: &DatabaseConnection, + ldk_data_dir: &Path, + #[cfg(feature = "vss")] remote: Option<&crate::vss_kv_store::VssKvStore>, +) -> Result<(), APIError> { + // Select keys first: refusing an opaque manager must not load or decode its payload. + let keys: Vec<(String, String, String)> = crate::runtime::block_on( + KvStoreEntity::find() + .select_only() + .columns([ + KvStoreColumn::PrimaryNamespace, + KvStoreColumn::SecondaryNamespace, + KvStoreColumn::Key, + ]) + .into_tuple() + .all(database), + ) + .map_err(|_| needs_review("local Lightning key inventory cannot be read"))?; + for (primary, secondary, key) in keys { + if is_common_config(&primary, &secondary, &key) { + continue; + } + if primary == PENDING_NAMESPACE && secondary.is_empty() && is_common_remote_key(&key) { + let row = crate::runtime::block_on( + KvStoreEntity::find_by_id((primary, secondary, key.clone())).one(database), + ) + .map_err(|_| needs_review("local pending replication intent cannot be read"))? + .ok_or_else(|| { + needs_review("local pending replication inventory changed during startup") + })?; + check_pending_intent(&key, &row.value)?; + continue; + } + return Err(needs_review(format!( + "local Lightning store contains an unclassified key {}", + key_label(&primary, &secondary, &key) + ))); + } + + if crate::runtime::block_on(ChannelPeerEntity::find().one(database)) + .map_err(|_| needs_review("local Lightning peer inventory cannot be read"))? + .is_some() + { + return Err(needs_review("local Lightning peer history requires review")); + } + check_ldk_directory(ldk_data_dir)?; + + #[cfg(feature = "vss")] + if let Some(remote) = remote { + // list_all_keys excludes the ownership fence and paginates the complete raw inventory. + // No restore, pending-intent cleanup, put or delete is performed here. + let keys = remote + .list_all_keys() + .map_err(|_| needs_review("remote Lightning key inventory cannot be read"))?; + check_remote_keys(&keys)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::block_on; + use sea_orm::{ActiveModelTrait, ActiveValue}; + + // Keep setup, reads and preflight on the repository database runtime: SQLx schedules + // pooled connection releases there, so another runtime cannot drive that queued work. + fn inspect(database: &DatabaseConnection, ldk_data_dir: &Path) -> Result<(), APIError> { + check_mainnet_legacy_state( + database, + ldk_data_dir, + #[cfg(feature = "vss")] + None, + ) + } + + #[test] + fn only_exact_common_config_locations_are_allowed() { + for key in COMMON_CONFIG_KEYS { + assert!(is_common_config("rgb", "wallet_config", key)); + assert!(is_common_remote_key(&format!("rgb/wallet_config/{key}"))); + } + for (primary, secondary, key) in [ + ("", "", "manager"), + ("", "", "output_sweeper"), + ("monitors", "", "monitor"), + ("monitor_updates", "channel", "1"), + ("rgb", "wallet_config", "unknown"), + ("rgb", "", "indexer_url"), + ("rgb", "wallet_config", "indexer_url/manager"), + ] { + assert!(!is_common_config(primary, secondary, key)); + } + assert!(!is_common_remote_key("rgb//wallet_config/indexer_url")); + } + + #[test] + fn pending_config_put_and_delete_are_allowed_but_protocol_or_malformed_intents_are_not() { + let key = "rgb/wallet_config/indexer_url"; + assert!(check_pending_intent(key, b"\x01https://indexer.invalid").is_ok()); + assert!(check_pending_intent(key, &[0]).is_ok()); + for value in [&[][..], &[2][..], &[0, 1][..], &[1, 255][..]] { + assert!(check_pending_intent(key, value).is_err()); + } + for key in ["_/_/manager", "monitor_updates/channel/1", "malformed"] { + assert!(check_pending_intent(key, &[0]).is_err()); + assert!(check_pending_intent(key, &[1]).is_err()); + } + } + + #[test] + fn shared_logs_are_allowed_but_unclassified_files_remain_untouched() { + let temp = tempfile::tempdir().unwrap(); + let ldk_dir = temp.path().join(".ldk"); + assert!(check_ldk_directory(&ldk_dir).is_ok()); + std::fs::create_dir_all(ldk_dir.join(crate::utils::LOGS_DIR)).unwrap(); + std::fs::write( + ldk_dir.join(crate::utils::LOGS_DIR).join("logs.txt"), + b"log", + ) + .unwrap(); + assert!(check_ldk_directory(&ldk_dir).is_ok()); + let state_path = ldk_dir.join("funding_consignment"); + std::fs::write(&state_path, b"protected bytes").unwrap(); + assert!(check_ldk_directory(&ldk_dir).is_err()); + assert_eq!(std::fs::read(state_path).unwrap(), b"protected bytes"); + } + + #[cfg(unix)] + #[test] + fn symbolic_link_is_not_treated_as_the_shared_log_directory() { + let temp = tempfile::tempdir().unwrap(); + let ldk_dir = temp.path().join(".ldk"); + std::fs::create_dir(&ldk_dir).unwrap(); + std::os::unix::fs::symlink(temp.path(), ldk_dir.join(crate::utils::LOGS_DIR)).unwrap(); + assert!(check_ldk_directory(&ldk_dir).is_err()); + } + + #[cfg(feature = "vss")] + #[test] + fn remote_only_snapshots_and_noncanonical_keys_are_rejected_without_decoding() { + assert!(check_remote_keys(&[]).is_ok()); + assert!(check_remote_keys(&["rgb/wallet_config/bitcoin_network".into()]).is_ok()); + for key in [ + "_/_/manager", + "_/_/output_sweeper", + "monitor_updates/channel/1", + "rgb/pending_funding/id", + "vss_pending/_/rgb/wallet_config/indexer_url", + "rgb/wallet_config/bitcoin_network/extra", + "malformed", + ] { + assert!(check_remote_keys(&[key.into()]).is_err()); + } + } + + #[test] + fn diagnostic_keys_are_bounded_and_escape_control_characters() { + let label = key_label("", "", &format!("\n{}", "x".repeat(4096))); + assert!(!label.contains('\n')); + assert!(label.len() < 100); + } + + #[test] + fn database_preflight_accepts_fresh_wallet_and_preserves_ambiguous_snapshots() { + use rln_migration::{Migrator, MigratorTrait}; + + let temp = tempfile::tempdir().unwrap(); + let database = block_on(crate::utils::open_database_pool(temp.path())).unwrap(); + block_on(Migrator::up(&database, None)).unwrap(); + let ldk_data_dir = temp.path().join(".ldk"); + inspect(&database, &ldk_data_dir).expect("fresh wallet has no legacy state"); + + for key in COMMON_CONFIG_KEYS { + block_on( + crate::database::entities::KvStoreActMod { + primary_namespace: ActiveValue::Set("rgb".into()), + secondary_namespace: ActiveValue::Set("wallet_config".into()), + key: ActiveValue::Set(key.into()), + value: ActiveValue::Set(b"common value".to_vec()), + } + .insert(&database), + ) + .unwrap(); + } + inspect(&database, &ldk_data_dir).expect("common configuration is allowed"); + + block_on( + crate::database::entities::KvStoreActMod { + primary_namespace: ActiveValue::Set(String::new()), + secondary_namespace: ActiveValue::Set(String::new()), + key: ActiveValue::Set("manager".into()), + value: ActiveValue::Set(b"opaque legacy snapshot".to_vec()), + } + .insert(&database), + ) + .unwrap(); + let before = block_on(KvStoreEntity::find().all(&database)).unwrap(); + for _ in 0..2 { + let result = inspect(&database, &ldk_data_dir); + assert!( + matches!(&result, Err(APIError::MainnetLightningState(detail)) if detail.contains("unclassified key") && detail.contains("manager")), + "{result:?}" + ); + assert_eq!( + block_on(KvStoreEntity::find().all(&database)).unwrap(), + before + ); + } + block_on(database.close()).unwrap(); + } + + #[test] + fn database_preflight_never_cleans_or_replays_pending_intents() { + use rln_migration::{Migrator, MigratorTrait}; + + let temp = tempfile::tempdir().unwrap(); + let database = block_on(crate::utils::open_database_pool(temp.path())).unwrap(); + block_on(Migrator::up(&database, None)).unwrap(); + for (primary, secondary, key, value, accepted) in [ + ( + PENDING_NAMESPACE, + "", + "rgb/wallet_config/indexer_url", + &b"\x01https://indexer.invalid"[..], + true, + ), + ( + PENDING_NAMESPACE, + "", + "rgb/wallet_config/indexer_url", + &[0][..], + true, + ), + ( + PENDING_NAMESPACE, + "", + "rgb/wallet_config/indexer_url", + &[][..], + false, + ), + ( + PENDING_NAMESPACE, + "", + "rgb/wallet_config/indexer_url", + &[0, 1][..], + false, + ), + (PENDING_NAMESPACE, "", "_/_/manager", &[0][..], false), + (PENDING_NAMESPACE, "", "_/_/manager", &[1][..], false), + ("monitor_updates", "channel", "1", &b"opaque"[..], false), + ] { + block_on( + crate::database::entities::KvStoreActMod { + primary_namespace: ActiveValue::Set(primary.into()), + secondary_namespace: ActiveValue::Set(secondary.into()), + key: ActiveValue::Set(key.into()), + value: ActiveValue::Set(value.to_vec()), + } + .insert(&database), + ) + .unwrap(); + let before = block_on(KvStoreEntity::find().all(&database)).unwrap(); + let result = inspect(&database, &temp.path().join(".ldk")); + assert_eq!( + result.is_ok(), + accepted, + "{primary}/{secondary}/{key}: {result:?}" + ); + if !accepted { + assert!( + matches!(&result, Err(APIError::MainnetLightningState(detail)) if detail.contains(key)), + "{result:?}" + ); + } + assert_eq!( + block_on(KvStoreEntity::find().all(&database)).unwrap(), + before + ); + block_on( + KvStoreEntity::delete_by_id(( + primary.to_owned(), + secondary.to_owned(), + key.to_owned(), + )) + .exec(&database), + ) + .unwrap(); + } + block_on(database.close()).unwrap(); + } + + #[test] + fn database_preflight_preserves_peer_history() { + use rln_migration::{Migrator, MigratorTrait}; + + let temp = tempfile::tempdir().unwrap(); + let database = block_on(crate::utils::open_database_pool(temp.path())).unwrap(); + block_on(Migrator::up(&database, None)).unwrap(); + block_on( + crate::database::entities::ChannelPeerActMod { + pubkey: ActiveValue::Set("legacy peer".into()), + address: ActiveValue::Set("127.0.0.1:9735".into()), + created_at: ActiveValue::Set(chrono::Utc::now()), + } + .insert(&database), + ) + .unwrap(); + let before = block_on(ChannelPeerEntity::find().all(&database)).unwrap(); + let result = inspect(&database, &temp.path().join(".ldk")); + assert!( + matches!(&result, Err(APIError::MainnetLightningState(detail)) if detail == "local Lightning peer history requires review"), + "{result:?}" + ); + assert_eq!( + block_on(ChannelPeerEntity::find().all(&database)).unwrap(), + before + ); + block_on(database.close()).unwrap(); + } +} diff --git a/src/mainnet_vss_tests.rs b/src/mainnet_vss_tests.rs new file mode 100644 index 00000000..ab0bd4d9 --- /dev/null +++ b/src/mainnet_vss_tests.rs @@ -0,0 +1,541 @@ +//! Real VSS client traffic against a versioned in-memory protobuf server. This verifies client +//! integration and encrypted wallet recovery, not production server authentication or durability. + +use std::collections::{BTreeMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use rgb_lib::BitcoinNetwork; +use serde_json::{json, Value}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use vss_client::prost::Message; +use vss_client::types::{ + ErrorCode, ErrorResponse, GetObjectRequest, GetObjectResponse, KeyValue, + ListKeyVersionsRequest, ListKeyVersionsResponse, PutObjectRequest, PutObjectResponse, +}; + +use crate::args::UserArgs; +use crate::core_types::LdkChainSync; +use crate::error::APIError; +use crate::mainnet_startup_tests::Indexer; +use crate::utils::{start_daemon, AppState}; +use crate::{routes, sdk}; + +const PASSWORD: &str = "mainnet-vss-test-password"; +const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const FENCE: &str = "__rln_instance__"; + +#[derive(Default)] +struct Store { + values: BTreeMap, + version: i64, +} + +#[derive(Clone, Debug)] +struct Request { + method: String, + store: String, + keys: Vec, + deletes: Vec, + authenticated: bool, +} + +#[derive(Default)] +struct ServerState { + stores: BTreeMap, + requests: Vec, +} + +impl ServerState { + // Validate the complete transaction before changing any row, including fence CAS. + fn put(&mut self, request: PutObjectRequest) -> Result<(), ErrorCode> { + let store = self.stores.entry(request.store_id).or_default(); + if request.global_version.is_some_and(|v| v != store.version) { + return Err(ErrorCode::ConflictException); + } + let mut keys = HashSet::new(); + for value in request + .transaction_items + .iter() + .chain(&request.delete_items) + { + if !keys.insert(&value.key) || value.version < -1 { + return Err(ErrorCode::InvalidRequestException); + } + } + for value in &request.transaction_items { + let version = store.values.get(&value.key).map_or(0, |v| v.version); + if value.version != -1 && value.version != version { + return Err(ErrorCode::ConflictException); + } + } + for value in &request.delete_items { + if !store + .values + .get(&value.key) + .is_some_and(|existing| value.version == -1 || value.version == existing.version) + { + return Err(ErrorCode::ConflictException); + } + } + for mut value in request.transaction_items { + value.version = if value.version == -1 { + 1 + } else { + value.version + 1 + }; + store.values.insert(value.key.clone(), value); + } + for value in request.delete_items { + store.values.remove(&value.key); + } + store.version += 1; + Ok(()) + } +} + +fn protobuf(status: StatusCode, message: impl Message) -> Response { + ( + status, + [("content-type", "application/octet-stream")], + message.encode_to_vec(), + ) + .into_response() +} + +fn failure(code: ErrorCode) -> Response { + let status = match code { + ErrorCode::NoSuchKeyException => StatusCode::NOT_FOUND, + ErrorCode::ConflictException => StatusCode::CONFLICT, + _ => StatusCode::BAD_REQUEST, + }; + protobuf( + status, + ErrorResponse { + error_code: code as i32, + message: "fixture".into(), + }, + ) +} + +async fn handle( + State(state): State>>, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> Response { + let mut state = state.lock().unwrap(); + let authenticated = headers.contains_key("authorization"); + match uri.path() { + "/vss/getObject" => { + let request = GetObjectRequest::decode(body).unwrap(); + state.requests.push(Request { + method: "get".into(), + store: request.store_id.clone(), + keys: vec![request.key.clone()], + deletes: vec![], + authenticated, + }); + match state + .stores + .get(&request.store_id) + .and_then(|s| s.values.get(&request.key)) + { + Some(value) => protobuf( + StatusCode::OK, + GetObjectResponse { + value: Some(value.clone()), + }, + ), + None => failure(ErrorCode::NoSuchKeyException), + } + } + "/vss/putObjects" => { + let request = PutObjectRequest::decode(body).unwrap(); + state.requests.push(Request { + method: "put".into(), + store: request.store_id.clone(), + keys: request + .transaction_items + .iter() + .map(|v| v.key.clone()) + .collect(), + deletes: request.delete_items.iter().map(|v| v.key.clone()).collect(), + authenticated, + }); + match state.put(request) { + Ok(()) => protobuf(StatusCode::OK, PutObjectResponse {}), + Err(code) => failure(code), + } + } + "/vss/listKeyVersions" => { + let request = ListKeyVersionsRequest::decode(body).unwrap(); + state.requests.push(Request { + method: "list".into(), + store: request.store_id.clone(), + keys: vec![], + deletes: vec![], + authenticated, + }); + let offset: usize = request + .page_token + .as_deref() + .unwrap_or("0") + .parse() + .unwrap(); + let store = state.stores.get(&request.store_id); + // Deliberately small pages and reverse key order exercise complete inventory. + // VSS does not promise lexical order or a full requested page. + let values: Vec<_> = store + .into_iter() + .flat_map(|s| s.values.values().rev()) + .filter(|v| { + request + .key_prefix + .as_ref() + .is_none_or(|p| v.key.starts_with(p)) + }) + .collect(); + let page_size = request + .page_size + .filter(|size| *size > 0) + .unwrap_or(2) + .min(2) as usize; + let key_versions = values + .iter() + .skip(offset) + .take(page_size) + .map(|v| KeyValue { + key: v.key.clone(), + version: v.version, + value: vec![], + }) + .collect(); + let next = offset + page_size; + protobuf( + StatusCode::OK, + ListKeyVersionsResponse { + key_versions, + next_page_token: (next < values.len()).then(|| next.to_string()), + global_version: request + .page_token + .is_none() + .then(|| store.map_or(0, |s| s.version)), + }, + ) + } + _ => panic!("unexpected VSS method: {}", uri.path()), + } +} + +struct Server { + url: String, + state: Arc>, + task: JoinHandle<()>, +} +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} +impl Server { + async fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/vss", listener.local_addr().unwrap()); + let state = Arc::new(Mutex::new(ServerState::default())); + let router = Router::new() + .fallback(post(handle)) + .with_state(state.clone()); + let task = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + Self { url, state, task } + } + + fn rows(&self, store: &str) -> BTreeMap { + self.state + .lock() + .unwrap() + .stores + .get(store) + .map(|s| s.values.clone()) + .unwrap_or_default() + } +} + +struct Wallet { + state: Arc, + _directory: tempfile::TempDir, + indexer: Indexer, + proxy: String, + proxy_task: JoinHandle<()>, +} +impl Drop for Wallet { + fn drop(&mut self) { + self.proxy_task.abort(); + } +} +impl Wallet { + async fn new(server: &Server) -> Self { + let directory = tempfile::tempdir().unwrap(); + let indexer = Indexer::new(bitcoin::Network::Bitcoin).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = format!("rpc://{}/json-rpc", listener.local_addr().unwrap()); + let router = Router::new().route("/json-rpc", post(|Json(request): Json| async move { + assert_eq!(request["method"], "server.info"); + Json(json!({"jsonrpc":"2.0", "id":request["id"], "result":{"protocol_version":"0.2", "version":"fixture", "uptime":1}})) + })); + let proxy_task = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let state = start_daemon(&UserArgs { + storage_dir_path: directory.path().into(), + daemon_listening_port: 0, + ldk_peer_listening_port: 0, + network: BitcoinNetwork::Mainnet, + max_media_upload_size_mb: 1, + max_aggregated_media_size_per_channel_mb: 1, + max_pending_consignments: 10, + max_media_files_per_channel: 10, + root_public_key: None, + enable_virtual_channels_v0: false, + virtual_peer_pubkeys: vec![], + lsp_base_url: None, + lsp_bearer_token: None, + vss_url: Some(server.url.clone()), + vss_allow_empty_restore: false, + reuse_addresses: true, + remote_signer_listen_addr: None, + config: Default::default(), + }) + .await + .unwrap(); + sdk::init(state.clone(), PASSWORD.into(), Some(MNEMONIC.into())) + .await + .unwrap(); + Self { + state, + _directory: directory, + indexer, + proxy, + proxy_task, + } + } + + fn request(&self) -> sdk::UnlockRequest { + sdk::UnlockRequest { + password: PASSWORD.into(), + indexer_url: Some(self.indexer.url.clone()), + eth_rpc_url: None, + proxy_endpoint: Some(self.proxy.clone()), + announce_addresses: vec![], + announce_alias: None, + gossip_rgs_server_url: None, + ldk_chain_sync: { + #[cfg(feature = "block-sync")] + { + LdkChainSync::BlockSync { + bitcoind_rpc_username: "unused".into(), + bitcoind_rpc_password: "unused".into(), + bitcoind_rpc_host: "127.0.0.1".into(), + bitcoind_rpc_port: 1, + } + } + #[cfg(not(feature = "block-sync"))] + { + LdkChainSync::TransactionSync { + indexer_url: "tcp://127.0.0.1:1".into(), + } + } + }, + } + } + + async fn lock(&self) { + let _ = routes::lock(State(self.state.clone())).await.unwrap(); + assert!(self.state.unlocked_app_state.lock().await.is_none()); + assert!(self.state.ldk_background_services.lock().unwrap().is_none()); + } +} + +fn store_id() -> String { + let mnemonic = rgb_lib::bdk_wallet::keys::bip39::Mnemonic::parse(MNEMONIC).unwrap(); + crate::ldk::derive_vss_identity(&mnemonic, bitcoin::Network::Bitcoin) + .unwrap() + .pubkey_hex +} + +#[test] +fn versioned_fixture_rejects_conflicts_atomically() { + let mut state = ServerState::default(); + let put = |items| PutObjectRequest { + store_id: "s".into(), + global_version: None, + transaction_items: items, + delete_items: vec![], + }; + let value = |key: &str, version| KeyValue { + key: key.into(), + version, + value: vec![1], + }; + state.put(put(vec![value("fence", 0)])).unwrap(); + assert_eq!( + state.put(put(vec![value("new", 0), value("fence", 0)])), + Err(ErrorCode::ConflictException) + ); + assert!(!state.stores["s"].values.contains_key("new")); + assert_eq!(state.stores["s"].values["fence"].version, 1); + state.put(put(vec![value("fence", 1)])).unwrap(); + assert_eq!(state.stores["s"].values["fence"].version, 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_vss_wallet_backup_reopen_and_fresh_device_restore() { + let server = Server::new().await; + let wallet = Wallet::new(&server).await; + let node_store = store_id(); + let rgb_store = format!("{node_store}_rgb"); + sdk::unlock(wallet.state.clone(), wallet.request()) + .await + .unwrap(); + assert!(wallet + .state + .unlocked_app_state + .lock() + .await + .as_ref() + .unwrap() + .lightning + .is_none()); + assert!(wallet + .state + .ldk_background_services + .lock() + .unwrap() + .is_none()); + let first_fence = server.rows(&node_store)[FENCE].clone(); + let initial = sdk::address(wallet.state.clone()).await.unwrap().address; + let rotated = sdk::rotate_address(wallet.state.clone()) + .await + .unwrap() + .address; + assert_ne!(initial, rotated); + let version = sdk::vss_backup(wallet.state.clone()).await.unwrap(); + let backup = server.rows(&rgb_store); + assert_eq!(backup["backup/data"].version, version); + assert!(!backup["backup/data"].value.is_empty()); + let manifest: Value = serde_json::from_slice(&backup["backup/manifest"].value).unwrap(); + assert_eq!(manifest["encrypted"], true); + assert!(backup.contains_key("backup/metadata")); + + let other = Wallet::new(&server).await; + let conflict = sdk::unlock(other.state.clone(), other.request()).await; + assert!( + matches!(&conflict, Err(APIError::FailedVssInit(detail)) if detail.contains("owned by another")), + "{conflict:?}" + ); + assert_eq!(server.rows(&node_store)[FENCE], first_fence); + assert!(other.state.unlocked_app_state.lock().await.is_none()); + + wallet.lock().await; + assert!(!server.rows(&node_store).contains_key(FENCE)); + sdk::unlock(wallet.state.clone(), wallet.request()) + .await + .unwrap(); + assert_ne!(server.rows(&node_store)[FENCE].value, first_fence.value); + assert_eq!( + sdk::address(wallet.state.clone()).await.unwrap().address, + rotated + ); + wallet.lock().await; + let request_start = server.state.lock().unwrap().requests.len(); + sdk::unlock(other.state.clone(), other.request()) + .await + .unwrap(); + assert_eq!( + sdk::address(other.state.clone()).await.unwrap().address, + rotated + ); + assert!(other + .state + .unlocked_app_state + .lock() + .await + .as_ref() + .unwrap() + .lightning + .is_none()); + other.lock().await; + assert!(!server.rows(&node_store).contains_key(FENCE)); + let state = server.state.lock().unwrap(); + assert!(state.requests[request_start..] + .iter() + .any(|r| r.method == "get" && r.store == rgb_store && r.keys == ["backup/data"])); + assert!(state.requests.iter().all(|r| r.authenticated)); + assert!(state.stores[&node_store] + .values + .keys() + .all(|key| key.starts_with("rgb/wallet_config/"))); + assert!(state + .requests + .iter() + .filter(|r| r.store == node_store) + .all(|r| { + r.keys + .iter() + .chain(&r.deletes) + .all(|key| key == FENCE || key.starts_with("rgb/wallet_config/")) + })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_vss_remote_only_manager_on_later_page_is_preserved_and_fence_released() { + let server = Server::new().await; + let wallet = Wallet::new(&server).await; + let node_store = store_id(); + { + let mut state = server.state.lock().unwrap(); + let store = state.stores.entry(node_store.clone()).or_default(); + for key in [ + "rgb/wallet_config/indexer_url", + "rgb/wallet_config/bitcoin_network", + "_/_/manager", + ] { + store.values.insert( + key.into(), + KeyValue { + key: key.into(), + version: 1, + value: b"opaque bytes must remain untouched".to_vec(), + }, + ); + } + } + let before = server.rows(&node_store); + let result = sdk::unlock(wallet.state.clone(), wallet.request()).await; + assert!( + matches!(&result, Err(APIError::MainnetLightningState(detail)) if detail.contains("remote") && detail.contains("manager")), + "{result:?}" + ); + assert_eq!(server.rows(&node_store), before); + assert!(wallet.state.unlocked_app_state.lock().await.is_none()); + assert!(wallet + .state + .ldk_background_services + .lock() + .unwrap() + .is_none()); + assert!(!*wallet.state.changing_state.lock().unwrap()); + let state = server.state.lock().unwrap(); + assert!(state.requests.iter().filter(|r| r.method == "list").count() >= 2); + assert!(state.requests.iter().all(|r| r.store == node_store)); + assert!(state + .requests + .iter() + .filter(|r| r.method != "list") + .all(|r| r.keys.iter().chain(&r.deletes).all(|key| key == FENCE))); +} diff --git a/src/node.rs b/src/node.rs index c05dbc7f..18c80ee9 100644 --- a/src/node.rs +++ b/src/node.rs @@ -4,7 +4,7 @@ use rgb_lib::BitcoinNetwork; use crate::args::UserArgs; use crate::error::AppError; -use crate::ldk::stop_ldk; +use crate::ldk::stop_node; use crate::utils::{start_daemon, AppState}; pub struct NodeConfig { @@ -79,7 +79,12 @@ impl NodeHandle { pub async fn shutdown(&self) { self.state.cancel_token.cancel(); - stop_ldk(self.state.clone()).await; + // An unlock in progress owns resources that have not been published yet. Its state-change + // guard finishes publication/rollback before shutdown takes the session for teardown. + while *self.state.get_changing_state() { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + stop_node(self.state.clone()).await; } #[cfg(feature = "uniffi")] diff --git a/src/node_info.rs b/src/node_info.rs new file mode 100644 index 00000000..1843e1dc --- /dev/null +++ b/src/node_info.rs @@ -0,0 +1,191 @@ +use crate::error::APIError; +use crate::utils::UnlockedAppState; +use lightning::chain::channelmonitor::Balance; + +/// Lightning fields in the shared node-info response. Wallet balances are reported separately. +#[derive(Default)] +pub(crate) struct LightningInfo { + pub(crate) num_channels: usize, + pub(crate) num_usable_channels: usize, + pub(crate) local_balance_sat: u64, + pub(crate) eventual_close_fees_sat: u64, + pub(crate) pending_outbound_payments_sat: u64, + pub(crate) num_peers: usize, + pub(crate) network_nodes: usize, + pub(crate) network_channels: usize, + pub(crate) latest_rgs_snapshot_timestamp: Option, +} + +impl LightningInfo { + pub(crate) fn from_state(state: &UnlockedAppState) -> Self { + let Some(lightning) = state.lightning.as_ref() else { + return Self::default(); + }; + let channels = lightning.channel_manager.list_channels(); + let balances = lightning.chain_monitor.get_claimable_balances(&[]); + let graph = lightning.network_graph.read_only(); + Self { + num_channels: channels.len(), + num_usable_channels: channels.iter().filter(|channel| channel.is_usable).count(), + local_balance_sat: balances + .iter() + .map(Balance::claimable_amount_satoshis) + .sum(), + eventual_close_fees_sat: balances + .iter() + .map(|balance| match balance { + Balance::ClaimableOnChannelClose { + balance_candidates, + confirmed_balance_candidate_index, + .. + } => { + balance_candidates[*confirmed_balance_candidate_index] + .transaction_fee_satoshis + } + _ => 0, + }) + .sum(), + pending_outbound_payments_sat: balances + .iter() + .map(|balance| match balance { + Balance::MaybeTimeoutClaimableHTLC { + amount_satoshis, + outbound_payment: true, + .. + } => *amount_satoshis, + _ => 0, + }) + .sum(), + num_peers: lightning.peer_manager.list_peers().len(), + network_nodes: graph.nodes().len(), + network_channels: graph.channels().len(), + latest_rgs_snapshot_timestamp: lightning + .network_graph + .get_last_rapid_gossip_sync_timestamp() + .map(u64::from), + } + } +} + +/// Read the wallet indexer's tip without constructing an LDK chain backend or polling worker. +/// The caller releases the lifecycle lock before awaiting the query. Client timeouts limit +/// socket operations; Electrum hostname resolution still uses the system resolver's timing. +pub(crate) async fn mainnet_height(indexer_url: String) -> Result { + tokio::task::spawn_blocking(move || read_mainnet_height(&indexer_url, 5)) + .await + .map_err(|err| APIError::Unexpected(format!("indexer height task failed: {err}")))? +} + +fn read_mainnet_height(indexer_url: &str, timeout_seconds: u8) -> Result { + let genesis = + bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Bitcoin).block_hash(); + #[cfg(feature = "electrum")] + if !indexer_url.starts_with("http://") && !indexer_url.starts_with("https://") { + use electrum_client::ElectrumApi; + let config = electrum_client::ConfigBuilder::new() + .timeout(Some(timeout_seconds)) + .retry(0) + .build(); + let client = electrum_client::Client::from_config(indexer_url, config).map_err(|err| { + APIError::Network(format!("cannot read wallet indexer height: {err}")) + })?; + let header = client.block_header(0).map_err(|err| { + APIError::Network(format!("cannot validate wallet indexer network: {err}")) + })?; + if header.block_hash() != genesis { + return Err(APIError::InvalidIndexer( + "wallet indexer is not on mainnet".into(), + )); + } + let tip = client.block_headers_subscribe().map_err(|err| { + APIError::Network(format!("cannot read wallet indexer height: {err}")) + })?; + return u32::try_from(tip.height) + .map_err(|_| APIError::Network("wallet indexer returned an invalid height".into())); + } + #[cfg(feature = "esplora")] + if indexer_url.starts_with("https://") || indexer_url.starts_with("http://") { + let client = esplora_client::Builder::new(indexer_url) + .timeout(u64::from(timeout_seconds)) + .max_retries(0) + .build_blocking(); + let remote_genesis = client.get_block_hash(0).map_err(|err| { + APIError::Network(format!("cannot validate wallet indexer network: {err}")) + })?; + if remote_genesis != genesis { + return Err(APIError::InvalidIndexer( + "wallet indexer is not on mainnet".into(), + )); + } + return client + .get_height() + .map_err(|err| APIError::Network(format!("cannot read wallet indexer height: {err}"))); + } + Err(APIError::InvalidIndexer( + "unsupported wallet indexer protocol".into(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn indexer_that_accepts_without_reply_does_not_leave_a_blocked_worker() { + #[allow(unused_mut)] + let mut schemes = Vec::new(); + #[cfg(feature = "electrum")] + schemes.push("tcp"); + #[cfg(feature = "esplora")] + schemes.push("http"); + for scheme in schemes { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("{scheme}://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (_socket, _) = listener.accept().await.unwrap(); + std::future::pending::<()>().await; + }); + let started = Instant::now(); + let result = tokio::task::spawn_blocking(move || read_mainnet_height(&url, 1)) + .await + .unwrap(); + assert!( + matches!(result, Err(APIError::Network(_))), + "{scheme}: {result:?}" + ); + assert!(started.elapsed() < Duration::from_secs(4), "{scheme}"); + // Awaiting the worker above, rather than timing out its JoinHandle, proves it exited. + server.abort(); + } + } + + #[cfg(feature = "esplora")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn esplora_height_is_live_and_validates_the_network() { + for network in [bitcoin::Network::Bitcoin, bitcoin::Network::Regtest] { + let genesis = bitcoin::blockdata::constants::genesis_block(network) + .block_hash() + .to_string(); + let router = axum::Router::new() + .route( + "/block-height/0", + axum::routing::get(move || async move { genesis }), + ) + .route( + "/blocks/tip/height", + axum::routing::get(|| async { "123456" }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let result = mainnet_height(url).await; + if network == bitcoin::Network::Bitcoin { + assert_eq!(result.unwrap(), 123456); + } else { + assert!(matches!(result, Err(APIError::InvalidIndexer(_)))); + } + server.abort(); + } + } +} diff --git a/src/rgb.rs b/src/rgb.rs index 5c2fd568..88d43898 100644 --- a/src/rgb.rs +++ b/src/rgb.rs @@ -33,7 +33,7 @@ use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, Mutex, MutexGuard}; -use crate::{error::APIError, utils::UnlockedAppState}; +use crate::{error::APIError, utils::CommonState}; /// When `sign_rgb_psbt` fails, internal mode falls back to the local RGB wallet; external mode does not. fn resolve_rgb_psbt_signer_failure( @@ -112,7 +112,7 @@ pub(crate) fn rgb_signer_descriptors_for_psbt_with_context( Ok(descriptors) } -impl UnlockedAppState { +impl CommonState { fn rgb_signer_descriptors_for_psbt( &self, unsigned_psbt: &str, diff --git a/src/routes.rs b/src/routes.rs index 5ace30ba..0b19e3be 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -14,7 +14,6 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use bitcoin::{Network, ScriptBuf}; use hex::DisplayHex; -use lightning::chain::channelmonitor::Balance; use lightning::ln::{channelmanager::OptionalOfferPaymentParams, types::ChannelId}; use lightning::offers::offer::{self, Offer}; use lightning::onion_message::messenger::Destination; @@ -92,7 +91,7 @@ use crate::core_types::async_order::{ }; use crate::error::error_name; use crate::ldk::{ - clear_rgb_payment_pending, peer_has_live_channel, start_ldk, stop_ldk, LdkBackgroundServices, + clear_rgb_payment_pending, peer_has_live_channel, start_node, stop_node, LdkBackgroundServices, VirtualChannelSessionStatus, }; #[cfg(feature = "vss")] @@ -1769,6 +1768,7 @@ impl AppState { ) -> Result>>, APIError> { self.check_changing_state()?; let unlocked_app_state = self.get_unlocked_app_state().await; + self.check_changing_state()?; if unlocked_app_state.is_some() { Err(APIError::UnlockedNode) } else { @@ -1781,6 +1781,7 @@ impl AppState { ) -> Result>>, APIError> { self.check_changing_state()?; let unlocked_app_state = self.get_unlocked_app_state().await; + self.check_changing_state()?; if unlocked_app_state.is_none() { Err(APIError::LockedNode) } else { @@ -1852,7 +1853,7 @@ pub(crate) async fn async_order_new( ) -> Result, APIError> { state.check_lightning_supported()?; let guard = state.check_unlocked().await?; - let unlocked_state = Arc::clone(guard.as_ref().unwrap()); + let unlocked_state = Arc::clone(guard.as_ref().unwrap().lightning()?); drop(guard); let host_node_id = @@ -1962,7 +1963,7 @@ pub(crate) async fn async_order_outbound_invoice( ) -> Result, APIError> { state.check_lightning_supported()?; let guard = state.check_unlocked().await?; - let unlocked_state = Arc::clone(guard.as_ref().unwrap()); + let unlocked_state = Arc::clone(guard.as_ref().unwrap().lightning()?); drop(guard); let peer_node_id = @@ -2046,7 +2047,11 @@ pub(crate) async fn asset_balance( let mut offchain_outbound = 0; let mut offchain_inbound = 0; - for chan_info in unlocked_state.channel_manager.list_channels() { + for chan_info in unlocked_state + .lightning + .iter() + .flat_map(|lightning| lightning.channel_manager.list_channels()) + { let channel_id_str = chan_info.channel_id.0.as_hex().to_string(); let rgb_info = match unlocked_state @@ -2215,7 +2220,7 @@ pub(crate) async fn cancel_hodl_invoice( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; let payment_info = unlocked_state @@ -2293,7 +2298,7 @@ pub(crate) async fn claim_hodl_invoice( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; let preimage = @@ -2378,7 +2383,7 @@ pub(crate) async fn close_channel( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let channel_id_vec = hex_str_to_vec(&payload.channel_id); if channel_id_vec.is_none() || channel_id_vec.as_ref().unwrap().len() != 32 { @@ -2565,7 +2570,7 @@ pub(crate) async fn connect_peer( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let (peer_pubkey, peer_addr) = parse_peer_info(payload.peer_pubkey_and_addr.to_string())?; @@ -2709,7 +2714,7 @@ pub(crate) async fn disconnect_peer( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let peer_pubkey = match PublicKey::from_str(&payload.peer_pubkey) { Ok(pubkey) => pubkey, @@ -2815,7 +2820,13 @@ pub(crate) async fn get_channel_id( ) -> Result, APIError> { state.check_lightning_supported()?; let tmp_chan_id = check_channel_id(&payload.temporary_channel_id)?; - let channel_ids = state.check_unlocked().await?.clone().unwrap().channel_ids(); + let channel_ids = state + .check_unlocked() + .await? + .as_ref() + .unwrap() + .lightning()? + .channel_ids(); let channel_id = if let Some(channel_id) = channel_ids.get(&tmp_chan_id) { channel_id.0.as_hex().to_string() } else { @@ -2863,7 +2874,7 @@ pub(crate) async fn get_payment( ) -> Result, APIError> { state.check_lightning_supported()?; let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let requested_ph = validate_and_parse_payment_hash(&payload.payment_hash)?; @@ -2942,7 +2953,7 @@ pub(crate) async fn get_swap( ) -> Result, APIError> { state.check_lightning_supported()?; let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let requested_ph = validate_and_parse_payment_hash(&payload.payment_hash)?; @@ -3168,7 +3179,7 @@ pub(crate) async fn invoice_status( ) -> Result, APIError> { state.check_lightning_supported()?; let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let invoice = match Bolt11Invoice::from_str(&payload.invoice) { Err(e) => return Err(APIError::InvalidInvoice(e.to_string())), @@ -3335,7 +3346,7 @@ pub(crate) async fn keysend( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let dest_pubkey = match hex_str_to_compressed_pubkey(&payload.dest_pubkey) { Some(pk) => pk, @@ -3456,7 +3467,11 @@ pub(crate) async fn list_assets( )?; let mut offchain_balances = HashMap::new(); - for chan_info in unlocked_state.channel_manager.list_channels() { + for chan_info in unlocked_state + .lightning + .iter() + .flat_map(|lightning| lightning.channel_manager.list_channels()) + { let channel_id_str = chan_info.channel_id.0.as_hex().to_string(); let rgb_info = match unlocked_state @@ -3557,7 +3572,7 @@ pub(crate) async fn list_channels( ) -> Result, APIError> { state.check_lightning_supported()?; let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let mut channels = vec![]; let virtual_sessions = unlocked_state.virtual_channel_session_store(); @@ -3643,7 +3658,7 @@ pub(crate) async fn list_payments( ) -> Result, APIError> { state.check_lightning_supported()?; let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let inbound_payments = unlocked_state.list_updated_inbound_payments(); let outbound_payments = unlocked_state.outbound_payments(); @@ -3736,7 +3751,7 @@ pub(crate) async fn list_peers( ) -> Result, APIError> { state.check_lightning_supported()?; let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let mut peers = vec![]; for peer_details in unlocked_state.peer_manager.list_peers() { @@ -3753,7 +3768,7 @@ pub(crate) async fn list_swaps( ) -> Result, APIError> { state.check_lightning_supported()?; let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let map_swap = |payment_hash: &PaymentHash, swap_data: &SwapData, taker: bool| { let mut status = swap_data.status; @@ -4002,7 +4017,7 @@ pub(crate) async fn ln_invoice( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let contract_id = if let Some(asset_id) = &payload.asset_id { Some( @@ -4116,7 +4131,7 @@ pub(crate) async fn lock( }; tracing::debug!("Stopping LDK..."); - stop_ldk(state.clone()).await; + stop_node(state.clone()).await; tracing::debug!("LDK stopped"); state.update_unlocked_app_state(None).await; @@ -4136,7 +4151,7 @@ pub(crate) async fn maker_execute( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let swapstring = SwapString::from_str(&payload.swapstring) .map_err(|e| APIError::InvalidSwapString(payload.swapstring.clone(), e.to_string()))?; @@ -4358,7 +4373,7 @@ pub(crate) async fn maker_init( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let from_asset = match &payload.from_asset { None => None, @@ -4438,11 +4453,17 @@ pub(crate) async fn network_info( let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); - let best_block = unlocked_state.channel_manager.current_best_block(); + let height = if let Some(lightning) = &unlocked_state.lightning { + lightning.channel_manager.current_best_block().height + } else { + let indexer_url = unlocked_state.indexer_url.clone(); + drop(guard); + crate::node_info::mainnet_height(indexer_url).await? + }; Ok(Json(NetworkInfoResponse { network: state.static_state.network.into(), - height: best_block.height, + height, })) } @@ -4452,51 +4473,16 @@ pub(crate) async fn node_info( let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); - let chans = unlocked_state.channel_manager.list_channels(); - - let balances = unlocked_state.chain_monitor.get_claimable_balances(&[]); - let local_balance_sat = balances - .iter() - .map(|b| b.claimable_amount_satoshis()) - .sum::(); - - let close_fees_map = |b| match b { - &Balance::ClaimableOnChannelClose { - ref balance_candidates, - confirmed_balance_candidate_index, - .. - } => balance_candidates[confirmed_balance_candidate_index].transaction_fee_satoshis, - _ => 0, - }; - let eventual_close_fees_sat = balances.iter().map(close_fees_map).sum::(); - - let pending_payments_map = |b| match b { - &Balance::MaybeTimeoutClaimableHTLC { - amount_satoshis, - outbound_payment, - .. - } if outbound_payment => amount_satoshis, - _ => 0, - }; - let pending_outbound_payments_sat = balances.iter().map(pending_payments_map).sum::(); - - let graph_lock = unlocked_state.network_graph.read_only(); - let network_nodes = graph_lock.nodes().len(); - let network_channels = graph_lock.channels().len(); - - let latest_rgs_snapshot_timestamp = unlocked_state - .network_graph - .get_last_rapid_gossip_sync_timestamp() - .map(|val| val as u64); + let lightning = crate::node_info::LightningInfo::from_state(unlocked_state); Ok(Json(NodeInfoResponse { pubkey: unlocked_state.runtime_node_pubkey(), - num_channels: chans.len(), - num_usable_channels: chans.iter().filter(|c| c.is_usable).count(), - local_balance_sat, - eventual_close_fees_sat, - pending_outbound_payments_sat, - num_peers: unlocked_state.peer_manager.list_peers().len(), + num_channels: lightning.num_channels, + num_usable_channels: lightning.num_usable_channels, + local_balance_sat: lightning.local_balance_sat, + eventual_close_fees_sat: lightning.eventual_close_fees_sat, + pending_outbound_payments_sat: lightning.pending_outbound_payments_sat, + num_peers: lightning.num_peers, account_xpub_vanilla: unlocked_state.rgb_get_keys().account_xpub_vanilla, account_xpub_colored: unlocked_state.rgb_get_keys().account_xpub_colored, max_media_upload_size_mb: state.static_state.max_media_upload_size_mb, @@ -4506,19 +4492,22 @@ pub(crate) async fn node_info( channel_capacity_max_sat: unlocked_state.config.channels.open_max_sat, channel_asset_min_amount: unlocked_state.config.channels.open_min_rgb_amount, channel_asset_max_amount: u64::MAX, - network_nodes, - network_channels, - latest_rgs_snapshot_timestamp, + network_nodes: lightning.network_nodes, + network_channels: lightning.network_channels, + latest_rgs_snapshot_timestamp: lightning.latest_rgs_snapshot_timestamp, })) } struct OpenChannelVirtualIntentGuard { - unlocked_state: Arc, + unlocked_state: Arc, temporary_channel_id: Option, } impl OpenChannelVirtualIntentGuard { - fn new(unlocked_state: Arc, temporary_channel_id: ChannelId) -> Self { + fn new( + unlocked_state: Arc, + temporary_channel_id: ChannelId, + ) -> Self { Self { unlocked_state, temporary_channel_id: Some(temporary_channel_id), @@ -4546,7 +4535,7 @@ pub(crate) async fn open_channel( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; // Channel persistence is remote-first: without VSS the open would // accept and then stall silently, so refuse it up front. @@ -5249,7 +5238,7 @@ pub(crate) async fn send_onion_message( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; if payload.node_ids.is_empty() { return Err(APIError::InvalidNodeIds(s!( @@ -5315,7 +5304,7 @@ pub(crate) async fn send_payment( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let mut status = HTLCStatus::Pending; let created_at = get_current_timestamp(); @@ -5683,7 +5672,7 @@ pub(crate) async fn taker( state.check_lightning_supported()?; no_cancel(async move { let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let swapstring = SwapString::from_str(&payload.swapstring) .map_err(|e| APIError::InvalidSwapString(payload.swapstring.clone(), e.to_string()))?; @@ -5722,7 +5711,7 @@ async fn external_signer_key_source( ) -> Result { // Remote external signer (Option A): the daemon holds the seed and answers all signing // (identity/scripts/channel/node-crypto/RGB PSBT) over framed TCP. The persisted - // key_source.json is validated against the daemon's bootstrap inside start_ldk. + // key_source.json is validated against the daemon's bootstrap inside start_node. let attachment = crate::signer::remote::connect_daemon_attachment(state).await?; Ok(crate::core_types::NodeKeySource::External( crate::core_types::ExternalKeySource { @@ -5777,16 +5766,16 @@ pub(crate) async fn unlock( crate::core_types::NodeKeySource::InternalMnemonic(mnemonic) }; - tracing::debug!("Starting LDK..."); + tracing::debug!("Starting node..."); let (new_ldk_background_services, new_unlocked_app_state) = - start_ldk(state.clone(), key_source, payload.into()).await?; - tracing::debug!("LDK started"); + start_node(state.clone(), key_source, payload.into()).await?; + tracing::debug!("Node started"); state .update_unlocked_app_state(Some(new_unlocked_app_state)) .await; - state.update_ldk_background_services(Some(new_ldk_background_services)); + state.update_ldk_background_services(new_ldk_background_services); tracing::info!("Unlock completed"); Ok(Json(EmptyResponse {})) @@ -5798,26 +5787,28 @@ pub(crate) async fn unlock( pub(crate) async fn vss_backup( State(state): State>, ) -> Result, APIError> { - let guard = state.check_unlocked().await?; - let unlocked_state = guard.as_ref().unwrap().clone(); - drop(guard); - - let vss_client = unlocked_state - .rgb_wallet_wrapper - .vss_client() - .ok_or_else(|| APIError::Unexpected("VSS is not configured".to_string()))?; + crate::utils::no_cancel(async move { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap().clone(); + + let vss_client = unlocked_state + .rgb_wallet_wrapper + .vss_client() + .ok_or_else(|| APIError::Unexpected("VSS is not configured".to_string()))?; + + let wrapper = unlocked_state.rgb_wallet_wrapper.clone(); + let version = tokio::task::spawn_blocking(move || { + let wallet = wrapper.get_rgb_wallet(); + let rt = vss_client.handle().clone(); + rt.block_on(wallet.vss_backup(&vss_client)) + }) + .await + .map_err(|e| APIError::Unexpected(format!("VSS backup task failed: {e}")))? + .map_err(|e| APIError::Unexpected(format!("VSS backup failed: {e}")))?; - let wrapper = unlocked_state.rgb_wallet_wrapper.clone(); - let version = tokio::task::spawn_blocking(move || { - let wallet = wrapper.get_rgb_wallet(); - let rt = vss_client.handle().clone(); - rt.block_on(wallet.vss_backup(&vss_client)) + Ok(Json(serde_json::json!({ "version": version }))) }) .await - .map_err(|e| APIError::Unexpected(format!("VSS backup task failed: {e}")))? - .map_err(|e| APIError::Unexpected(format!("VSS backup failed: {e}")))?; - - Ok(Json(serde_json::json!({ "version": version }))) } #[cfg(feature = "vss")] @@ -5874,7 +5865,7 @@ pub(crate) async fn vss_clear_fence( // Internal-mnemonic mode authenticates with the password and derives // the `m/535'/1'` identity. External-signer mode holds no mnemonic, so // it reconstructs the bootstrap identity from the persisted - // key_source.json — the same store id start_ldk acquired the fence + // key_source.json — the same store id start_node acquired the fence // under. Without this branch, external-signer nodes could never clear // a leftover fence and would be wedged after their first shutdown. // [[derive_vss_identity_from_key_source]] diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs index 99d66f6b..de06f278 100644 --- a/src/sdk/mod.rs +++ b/src/sdk/mod.rs @@ -17,7 +17,7 @@ use crate::core_types::async_order::{ use crate::core_types::PENDING_SWAP_TIMEOUT_SECS; use crate::error::APIError; use crate::ldk::{ - clear_rgb_payment_pending, peer_has_live_channel, start_ldk, write_rgb_payment_info_file, + clear_rgb_payment_pending, peer_has_live_channel, start_node, write_rgb_payment_info_file, InvoiceType, PaymentInfo, VirtualChannelSessionStatus, }; #[cfg(feature = "vss")] @@ -45,7 +45,6 @@ use bitcoin::hashes::Hash; use bitcoin::hex::DisplayHex; use bitcoin::secp256k1::PublicKey; use bitcoin::ScriptBuf; -use lightning::chain::channelmonitor::Balance; use lightning::ln::channel_state::ChannelShutdownState; use lightning::ln::channelmanager::Bolt11InvoiceParameters; use lightning::ln::channelmanager::{ @@ -111,13 +110,13 @@ use serde_json::Value; const SDK_VIRTUAL_OPEN_MODE_TRUSTED_NO_BROADCAST: &str = "trusted_no_broadcast"; struct OpenChannelVirtualIntentGuard { - unlocked_state: Arc, + unlocked_state: Arc, temporary_channel_id: Option, } impl OpenChannelVirtualIntentGuard { fn new( - unlocked_state: Arc, + unlocked_state: Arc, temporary_channel_id: ChannelId, ) -> Self { Self { @@ -152,6 +151,7 @@ async fn check_locked( ) -> Result>>, APIError> { check_changing_state(state)?; let unlocked_app_state = state.unlocked_app_state.lock().await; + check_changing_state(state)?; if unlocked_app_state.is_some() { Err(APIError::UnlockedNode) } else { @@ -164,6 +164,7 @@ async fn check_unlocked( ) -> Result>>, APIError> { check_changing_state(state)?; let unlocked_app_state = state.unlocked_app_state.lock().await; + check_changing_state(state)?; if unlocked_app_state.is_none() { Err(APIError::LockedNode) } else { @@ -1177,52 +1178,18 @@ pub(crate) async fn node_info(state: Arc) -> Result(); - - let close_fees_map = |b| match b { - &Balance::ClaimableOnChannelClose { - ref balance_candidates, - confirmed_balance_candidate_index, - .. - } => balance_candidates[confirmed_balance_candidate_index].transaction_fee_satoshis, - _ => 0, - }; - let eventual_close_fees_sat = balances.iter().map(close_fees_map).sum::(); - - let pending_payments_map = |b| match b { - &Balance::MaybeTimeoutClaimableHTLC { - amount_satoshis, - outbound_payment, - .. - } if outbound_payment => amount_satoshis, - _ => 0, - }; - let pending_outbound_payments_sat = balances.iter().map(pending_payments_map).sum::(); - - let graph_lock = unlocked_state.network_graph.read_only(); - let network_nodes = graph_lock.nodes().len(); - let network_channels = graph_lock.channels().len(); - let latest_rgs_snapshot_timestamp = unlocked_state - .network_graph - .get_last_rapid_gossip_sync_timestamp() - .map(|val| val as u64); + let lightning = crate::node_info::LightningInfo::from_state(unlocked_state); let wallet_data = unlocked_state.rgb_get_keys(); Ok(NodeInfoData { pubkey: unlocked_state.runtime_node_pubkey(), - num_channels: chans.len(), - num_usable_channels: chans.iter().filter(|c| c.is_usable).count(), - local_balance_sat, - eventual_close_fees_sat, - pending_outbound_payments_sat, - num_peers: unlocked_state.peer_manager.list_peers().len(), + num_channels: lightning.num_channels, + num_usable_channels: lightning.num_usable_channels, + local_balance_sat: lightning.local_balance_sat, + eventual_close_fees_sat: lightning.eventual_close_fees_sat, + pending_outbound_payments_sat: lightning.pending_outbound_payments_sat, + num_peers: lightning.num_peers, account_xpub_vanilla: wallet_data.account_xpub_vanilla, account_xpub_colored: wallet_data.account_xpub_colored, max_media_upload_size_mb: state.static_state.max_media_upload_size_mb, @@ -1232,20 +1199,26 @@ pub(crate) async fn node_info(state: Arc) -> Result) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let best_block = unlocked_state.channel_manager.current_best_block(); + let height = if let Some(lightning) = &unlocked_state.lightning { + lightning.channel_manager.current_best_block().height + } else { + let indexer_url = unlocked_state.indexer_url.clone(); + drop(guard); + crate::node_info::mainnet_height(indexer_url).await? + }; Ok(NetworkInfoData { network: state.static_state.network, - height: best_block.height, + height, }) } @@ -1273,7 +1246,7 @@ pub(crate) async fn async_order_new( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = Arc::clone(guard.as_ref().unwrap()); + let unlocked_state = Arc::clone(guard.as_ref().unwrap().lightning()?); drop(guard); let host_node_id = @@ -1380,7 +1353,7 @@ pub(crate) async fn async_order_outbound_invoice( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = Arc::clone(guard.as_ref().unwrap()); + let unlocked_state = Arc::clone(guard.as_ref().unwrap().lightning()?); drop(guard); let peer_node_id = @@ -1538,7 +1511,12 @@ pub(crate) async fn get_channel_id( ) -> Result { state.check_lightning_supported()?; let tmp_chan_id = check_channel_id(&temporary_channel_id)?; - let channel_ids = check_unlocked(&state).await?.clone().unwrap().channel_ids(); + let channel_ids = check_unlocked(&state) + .await? + .as_ref() + .unwrap() + .lightning()? + .channel_ids(); let channel_id = channel_ids .get(&tmp_chan_id) .map(|channel_id| channel_id.0.as_hex().to_string()) @@ -1550,7 +1528,7 @@ pub(crate) async fn get_channel_id( pub(crate) async fn list_channels(state: Arc) -> Result, APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let mut channels = vec![]; let virtual_sessions = unlocked_state.virtual_channel_session_store(); @@ -1638,7 +1616,7 @@ pub(crate) async fn list_channels(state: Arc) -> Result) -> Result, APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; Ok(unlocked_state .peer_manager @@ -1663,7 +1641,11 @@ pub(crate) async fn asset_balance( let mut offchain_outbound = 0; let mut offchain_inbound = 0; - for chan_info in unlocked_state.channel_manager.list_channels() { + for chan_info in unlocked_state + .lightning + .iter() + .flat_map(|lightning| lightning.channel_manager.list_channels()) + { let channel_id_str = chan_info.channel_id.0.as_hex().to_string(); let rgb_info = match unlocked_state .kv_store @@ -1803,7 +1785,11 @@ pub(crate) async fn list_assets( let rgb_assets = unlocked_state.rgb_list_assets(filter_asset_schemas)?; let mut offchain_balances = HashMap::new(); - for chan_info in unlocked_state.channel_manager.list_channels() { + for chan_info in unlocked_state + .lightning + .iter() + .flat_map(|lightning| lightning.channel_manager.list_channels()) + { let channel_id_str = chan_info.channel_id.0.as_hex().to_string(); let rgb_info = match unlocked_state .kv_store @@ -2049,42 +2035,44 @@ pub(crate) async fn init_with_external_signer( /// callers reach for when they need a *deterministic* push — e.g. before /// shutting a node down or in tests that verify VSS restore. pub(crate) async fn vss_backup(state: Arc) -> Result { - let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap().clone(); - drop(guard); + crate::utils::no_cancel(async move { + let guard = check_unlocked(&state).await?; + let unlocked_state = guard.as_ref().unwrap().clone(); - #[cfg(not(feature = "vss"))] - { - let _ = unlocked_state; - Err(APIError::Unexpected( - "VSS support is not compiled in".to_string(), - )) - } + #[cfg(not(feature = "vss"))] + { + let _ = unlocked_state; + Err(APIError::Unexpected( + "VSS support is not compiled in".to_string(), + )) + } - #[cfg(feature = "vss")] - { - let vss_client = unlocked_state - .rgb_wallet_wrapper - .vss_client() - .ok_or_else(|| APIError::Unexpected("VSS is not configured".to_string()))?; - - let wrapper = unlocked_state.rgb_wallet_wrapper.clone(); - let version = tokio::task::spawn_blocking(move || { - let wallet = wrapper.get_rgb_wallet(); - let rt = vss_client.handle().clone(); - rt.block_on(wallet.vss_backup(&vss_client)) - }) - .await - .map_err(|e| APIError::Unexpected(format!("VSS backup task failed: {e}")))? - .map_err(|e| APIError::Unexpected(format!("VSS backup failed: {e}")))?; + #[cfg(feature = "vss")] + { + let vss_client = unlocked_state + .rgb_wallet_wrapper + .vss_client() + .ok_or_else(|| APIError::Unexpected("VSS is not configured".to_string()))?; + + let wrapper = unlocked_state.rgb_wallet_wrapper.clone(); + let version = tokio::task::spawn_blocking(move || { + let wallet = wrapper.get_rgb_wallet(); + let rt = vss_client.handle().clone(); + rt.block_on(wallet.vss_backup(&vss_client)) + }) + .await + .map_err(|e| APIError::Unexpected(format!("VSS backup task failed: {e}")))? + .map_err(|e| APIError::Unexpected(format!("VSS backup failed: {e}")))?; - Ok(version) - } + Ok(version) + } + }) + .await } /// Clears the VSS single-writer fence so a fresh instance can take over a -/// store whose previous owner did not release it (the normal case after any -/// shutdown — `acquire_fence` writes the fence but no code path deletes it). +/// store whose previous owner did not release it, for example after a crash +/// or an incomplete persistence flush during shutdown. /// /// Must be called on a locked node (the unlock path acquires the fence /// itself, so clearing it while unlocked would race against the periodic @@ -2114,9 +2102,9 @@ pub(crate) async fn vss_clear_fence( // Internal-mnemonic mode authenticates with the password and derives // the `m/535'/1'` identity. External-signer mode holds no mnemonic, so // it reconstructs the bootstrap identity from the persisted - // key_source.json — the same store id start_ldk acquired the fence + // key_source.json — the same store id start_node acquired the fence // under. Without this branch, external-signer nodes could never clear - // a leftover fence and would be wedged after their first shutdown. + // a leftover fence after an interrupted shutdown. // [[derive_vss_identity_from_key_source]] let identity = match read_key_source_file(&state.static_state.storage_dir_path) .map_err(|e| APIError::ExternalSignerProtocolError(e.to_string()))? @@ -2147,162 +2135,165 @@ pub(crate) async fn vss_clear_fence( } pub(crate) async fn unlock(state: Arc, request: UnlockRequest) -> Result<(), APIError> { - tracing::info!("Unlock started"); - if is_external_signer_mode_configured(&state)? { - return Err(APIError::ExternalSignerRequired); - } - - match check_locked(&state).await { - Ok(unlocked_state) => { - update_changing_state(&state, true); - drop(unlocked_state); - } - Err(e) => { - return Err(match e { - APIError::UnlockedNode => APIError::AlreadyUnlocked, - _ => e, - }); + crate::utils::no_cancel(async move { + tracing::info!("Unlock started"); + if is_external_signer_mode_configured(&state)? { + return Err(APIError::ExternalSignerRequired); } - } - let mnemonic = match check_password_validity(&request.password, &state.db()) { - Ok(mnemonic) => mnemonic, - Err(e) => { - update_changing_state(&state, false); - return Err(e); + match check_locked(&state).await { + Ok(unlocked_state) => { + update_changing_state(&state, true); + drop(unlocked_state); + } + Err(e) => { + return Err(match e { + APIError::UnlockedNode => APIError::AlreadyUnlocked, + _ => e, + }); + } } - }; - tracing::debug!("Starting LDK..."); - let gossip_source = request - .gossip_rgs_server_url - .map(|server_url| crate::gossip::GossipSourceConfig::RapidGossipSync { server_url }); - let unlock_request = crate::core_types::UnlockRequest { - ldk_chain_sync: request.ldk_chain_sync, - indexer_url: request.indexer_url, - eth_rpc_url: request.eth_rpc_url, - proxy_endpoint: request.proxy_endpoint, - announce_addresses: request.announce_addresses, - announce_alias: request.announce_alias, - gossip_source, - }; - let (new_ldk_background_services, new_unlocked_app_state) = match start_ldk( - state.clone(), - crate::core_types::NodeKeySource::InternalMnemonic(mnemonic), - unlock_request, - ) - .await - { - Ok((nlbs, nuap)) => (nlbs, nuap), - Err(e) => { - update_changing_state(&state, false); - return Err(e); + struct ChangingStateGuard(Arc); + impl Drop for ChangingStateGuard { + fn drop(&mut self) { + update_changing_state(&self.0, false); + } } - }; - tracing::debug!("LDK started"); + // Release the transition exactly once, after publication or failure. An earlier + // manual reset would let this guard clear a subsequent operation's transition. + let _changing_state_guard = ChangingStateGuard(Arc::clone(&state)); + + let mnemonic = check_password_validity(&request.password, &state.db())?; + + tracing::debug!("Starting node..."); + let gossip_source = request + .gossip_rgs_server_url + .map(|server_url| crate::gossip::GossipSourceConfig::RapidGossipSync { server_url }); + let unlock_request = crate::core_types::UnlockRequest { + ldk_chain_sync: request.ldk_chain_sync, + indexer_url: request.indexer_url, + eth_rpc_url: request.eth_rpc_url, + proxy_endpoint: request.proxy_endpoint, + announce_addresses: request.announce_addresses, + announce_alias: request.announce_alias, + gossip_source, + }; + let (new_ldk_background_services, new_unlocked_app_state) = start_node( + state.clone(), + crate::core_types::NodeKeySource::InternalMnemonic(mnemonic), + unlock_request, + ) + .await?; + tracing::debug!("Node started"); - update_unlocked_app_state(&state, Some(new_unlocked_app_state)).await; - update_ldk_background_services(&state, Some(new_ldk_background_services)); - update_changing_state(&state, false); - tracing::info!("Unlock completed"); - Ok(()) + update_unlocked_app_state(&state, Some(new_unlocked_app_state)).await; + update_ldk_background_services(&state, new_ldk_background_services); + tracing::info!("Unlock completed"); + Ok(()) + }) + .await } pub(crate) async fn unlock_with_attached_external_signer( state: Arc, request: UnlockRequest, ) -> Result<(), APIError> { - struct ChangingStateGuard { - state: Arc, - active: bool, - } + crate::utils::no_cancel(async move { + struct ChangingStateGuard { + state: Arc, + active: bool, + } - impl ChangingStateGuard { - fn new(state: Arc) -> Self { - Self { - state, - active: true, + impl ChangingStateGuard { + fn new(state: Arc) -> Self { + Self { + state, + active: true, + } } - } - fn disarm(&mut self) { - self.active = false; + fn disarm(&mut self) { + self.active = false; + } } - } - impl Drop for ChangingStateGuard { - fn drop(&mut self) { - if self.active { - update_changing_state(&self.state, false); + impl Drop for ChangingStateGuard { + fn drop(&mut self) { + if self.active { + update_changing_state(&self.state, false); + } } } - } - tracing::info!("Attached external-signer unlock started"); - match check_locked(&state).await { - Ok(unlocked_state) => { - update_changing_state(&state, true); - drop(unlocked_state); - } - Err(e) => { - return Err(match e { - APIError::UnlockedNode => APIError::AlreadyUnlocked, - _ => e, - }); + tracing::info!("Attached external-signer unlock started"); + match check_locked(&state).await { + Ok(unlocked_state) => { + update_changing_state(&state, true); + drop(unlocked_state); + } + Err(e) => { + return Err(match e { + APIError::UnlockedNode => APIError::AlreadyUnlocked, + _ => e, + }); + } } - } - let mut changing_state_guard = ChangingStateGuard::new(Arc::clone(&state)); + let mut changing_state_guard = ChangingStateGuard::new(Arc::clone(&state)); - let signer_attachment = match state.get_attached_external_signer().clone() { - Some(attachment) => attachment, - None => { - return Err(APIError::ExternalSignerUnavailable( - "attached external signer is not registered".to_string(), - )); + let signer_attachment = match state.get_attached_external_signer().clone() { + Some(attachment) => attachment, + None => { + return Err(APIError::ExternalSignerUnavailable( + "attached external signer is not registered".to_string(), + )); + } + }; + validate_external_signer_bootstrap(&signer_attachment.bootstrap)?; + let key_source = match read_key_source_file(&state.static_state.storage_dir_path) + .map_err(|e| APIError::ExternalSignerProtocolError(e.to_string()))? + { + Some(key_source) => key_source, + None => return Err(APIError::ExternalSignerRequired), + }; + if validate_key_source_matches_bootstrap(&key_source, &signer_attachment.bootstrap).is_err() + { + return Err(APIError::ExternalSignerMismatch); } - }; - validate_external_signer_bootstrap(&signer_attachment.bootstrap)?; - let key_source = match read_key_source_file(&state.static_state.storage_dir_path) - .map_err(|e| APIError::ExternalSignerProtocolError(e.to_string()))? - { - Some(key_source) => key_source, - None => return Err(APIError::ExternalSignerRequired), - }; - if validate_key_source_matches_bootstrap(&key_source, &signer_attachment.bootstrap).is_err() { - return Err(APIError::ExternalSignerMismatch); - } - let gossip_source = request - .gossip_rgs_server_url - .map(|server_url| crate::gossip::GossipSourceConfig::RapidGossipSync { server_url }); - let unlock_request = crate::core_types::UnlockRequest { - ldk_chain_sync: request.ldk_chain_sync, - indexer_url: request.indexer_url, - eth_rpc_url: request.eth_rpc_url, - proxy_endpoint: request.proxy_endpoint, - announce_addresses: request.announce_addresses, - announce_alias: request.announce_alias, - gossip_source, - }; - let (new_ldk_background_services, new_unlocked_app_state) = match start_ldk( - state.clone(), - crate::core_types::NodeKeySource::External(crate::core_types::ExternalKeySource { - bootstrap: signer_attachment.bootstrap.clone(), - signer_attachment, - }), - unlock_request, - ) - .await - { - Ok((nlbs, nuap)) => (nlbs, nuap), - Err(e) => return Err(e), - }; + let gossip_source = request + .gossip_rgs_server_url + .map(|server_url| crate::gossip::GossipSourceConfig::RapidGossipSync { server_url }); + let unlock_request = crate::core_types::UnlockRequest { + ldk_chain_sync: request.ldk_chain_sync, + indexer_url: request.indexer_url, + eth_rpc_url: request.eth_rpc_url, + proxy_endpoint: request.proxy_endpoint, + announce_addresses: request.announce_addresses, + announce_alias: request.announce_alias, + gossip_source, + }; + let (new_ldk_background_services, new_unlocked_app_state) = match start_node( + state.clone(), + crate::core_types::NodeKeySource::External(crate::core_types::ExternalKeySource { + bootstrap: signer_attachment.bootstrap.clone(), + signer_attachment, + }), + unlock_request, + ) + .await + { + Ok((nlbs, nuap)) => (nlbs, nuap), + Err(e) => return Err(e), + }; - update_unlocked_app_state(&state, Some(new_unlocked_app_state)).await; - update_ldk_background_services(&state, Some(new_ldk_background_services)); - changing_state_guard.disarm(); - update_changing_state(&state, false); - Ok(()) + update_unlocked_app_state(&state, Some(new_unlocked_app_state)).await; + update_ldk_background_services(&state, new_ldk_background_services); + changing_state_guard.disarm(); + update_changing_state(&state, false); + Ok(()) + }) + .await } pub(crate) async fn connect_peer( @@ -2311,7 +2302,7 @@ pub(crate) async fn connect_peer( ) -> Result<(), APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let (peer_pubkey, peer_addr) = parse_peer_info(peer_pubkey_and_addr.to_string())?; @@ -2336,7 +2327,7 @@ pub(crate) async fn disconnect_peer( ) -> Result<(), APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let peer_pubkey = PublicKey::from_str(&request.peer_pubkey).map_err(|_| APIError::InvalidPubkey)?; @@ -2373,7 +2364,7 @@ pub(crate) async fn close_channel( ) -> Result<(), APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let channel_id_vec = hex_str_to_vec(&request.channel_id); if channel_id_vec.is_none() || channel_id_vec.as_ref().unwrap().len() != 32 { @@ -2729,7 +2720,7 @@ pub(crate) async fn keysend( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let dest_pubkey_vec = match hex_str_to_vec(&request.dest_pubkey) { Some(peer_pubkey_vec) => peer_pubkey_vec, @@ -2941,7 +2932,7 @@ pub(crate) async fn open_channel( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let is_virtual_open = match request.virtual_open_mode.as_deref() { None => false, @@ -3288,7 +3279,7 @@ pub(crate) async fn send_payment( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let mut status = HtlcStatus::Pending; let created_at = get_current_timestamp(); @@ -3584,7 +3575,7 @@ pub(crate) async fn maker_execute( ) -> Result<(), APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let swapstring = SwapString::from_str(&request.swapstring) .map_err(|e| APIError::InvalidSwapString(request.swapstring.clone(), e.to_string()))?; @@ -3783,7 +3774,7 @@ pub(crate) async fn maker_init( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let from_asset = match &request.from_asset { None => None, @@ -3848,7 +3839,7 @@ pub(crate) async fn maker_init( pub(crate) async fn taker(state: Arc, request: TakerRequestData) -> Result<(), APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let swapstring = SwapString::from_str(&request.swapstring) .map_err(|e| APIError::InvalidSwapString(request.swapstring.clone(), e.to_string()))?; @@ -3878,7 +3869,7 @@ pub(crate) async fn send_onion_message( ) -> Result<(), APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; if request.node_ids.is_empty() { return Err(APIError::InvalidNodeIds(s!( @@ -3991,7 +3982,7 @@ pub(crate) async fn invoice_status( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let invoice = Bolt11Invoice::from_str(&invoice).map_err(|e| APIError::InvalidInvoice(e.to_string()))?; @@ -4033,7 +4024,7 @@ pub(crate) async fn create_ln_invoice( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let contract_id = if let Some(asset_id) = asset_id { Some(ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?) @@ -4136,7 +4127,7 @@ fn payment_type_from_invoice(invoice_type: Option) -> PaymentType { pub(crate) async fn list_payments(state: Arc) -> Result, APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; // Keep inbound invoice status consistent with expiry when payments are read. let inbound_payments = unlocked_state.list_updated_inbound_payments(); @@ -4203,7 +4194,7 @@ pub(crate) async fn get_payment( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let payment_hash_vec = hex_str_to_vec(&payment_hash_hex); if payment_hash_vec.is_none() || payment_hash_vec.as_ref().unwrap().len() != 32 { @@ -4282,7 +4273,7 @@ pub(crate) async fn cancel_hodl_invoice( ) -> Result<(), APIError> { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let payment_hash = validate_and_parse_payment_hash(&request.payment_hash)?; let payment_info = unlocked_state @@ -4312,7 +4303,7 @@ pub(crate) async fn claim_hodl_invoice( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let payment_hash = validate_and_parse_payment_hash(&request.payment_hash)?; let preimage = validate_and_parse_payment_preimage(&request.payment_preimage, &payment_hash)?; @@ -4449,7 +4440,7 @@ fn map_swap( payment_hash: &PaymentHash, swap_data: &SwapData, taker: bool, - state: &crate::utils::UnlockedAppState, + state: &crate::utils::LightningState, ) -> SwapViewData { let mut status: SwapStatus = swap_data.status; if status == SwapStatus::Waiting && get_current_timestamp() > swap_data.swap_info.expiry { @@ -4489,7 +4480,7 @@ pub(crate) async fn get_swap( ) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let payment_hash_vec = hex_str_to_vec(&payment_hash_hex); if payment_hash_vec.is_none() || payment_hash_vec.as_ref().unwrap().len() != 32 { @@ -4515,7 +4506,7 @@ pub(crate) async fn get_swap( pub(crate) async fn list_swaps(state: Arc) -> Result { state.check_lightning_supported()?; let guard = check_unlocked(&state).await?; - let unlocked_state = guard.as_ref().unwrap(); + let unlocked_state = guard.as_ref().unwrap().lightning()?; let taker_swaps = unlocked_state.taker_swaps(); let maker_swaps = unlocked_state.maker_swaps(); diff --git a/src/synced_kv_store.rs b/src/synced_kv_store.rs index 95b7cc33..526ccd65 100644 --- a/src/synced_kv_store.rs +++ b/src/synced_kv_store.rs @@ -352,7 +352,7 @@ impl SyncedKvStore { if !force { // Guard: refuse to clobber an already-populated local store. - // The caller in `start_ldk` also performs this check, but a + // The non-mainnet startup path also performs this check, but a // belt-and-suspenders guard makes this API hard to misuse. use lightning::util::persist::{ CHANNEL_MANAGER_PERSISTENCE_KEY, CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, @@ -457,6 +457,11 @@ impl SyncedKvStore { .remove(primary_namespace, secondary_namespace, key, false) } + #[cfg(feature = "vss")] + pub(crate) fn has_remote(&self) -> bool { + self.remote.is_some() + } + /// Returns the number of pending VSS-replication entries that failed and /// are awaiting retry. Surfaced via `/vssbackupinfo` so operators can /// alert on persistent backup-staleness. diff --git a/src/uniffi_api/README.md b/src/uniffi_api/README.md index 457d04ae..1c3a7d79 100644 --- a/src/uniffi_api/README.md +++ b/src/uniffi_api/README.md @@ -38,7 +38,18 @@ and propagates through the C ABI used by Node.js/Bare integrations. It runs befo Lightning execution, including while locked; binding argument conversion still applies. Bitcoin/RGB on-chain methods (including `rgbinvoice`, `decode_rgb_invoice`, asset linking, and transfers) and shared administration/identity methods keep their existing requirements. -Supported non-mainnet networks retain their existing behavior. +Mainnet unlock initializes the wallet and signer without the Lightning runtime. This +applies to internal, attached-external and native-external signer entry points. The +required `ldk_chain_sync` payload is retained but its backend is unused on mainnet. +`node_info` reports zero active Lightning values and no RGS timestamp; `network_info` +queries the wallet indexer on demand and can fail if that indexer is unavailable. +Identity, signing and configured RGB VSS backup keep their existing keys and stores. + +Persisted mainnet Lightning state causes `RlnError::MainnetLightningState` during unlock. +The appended error variant preserves existing error ordinals. This includes opaque +empty snapshots from older on-chain-only wallets; no state is deleted or resumed. +See [mainnet startup and recovery requirements](../../README.md) before upgrading an +existing wallet. Supported non-mainnet networks retain their existing behavior. ## Dependency layering @@ -51,7 +62,7 @@ Important notes: - UniFFI does not call HTTP route handlers; it calls SDK methods directly. - SDK is expected to depend on LDK core logic (it is a wrapper, not a separate node implementation). -- `ldk::start_ldk` now accepts `core_types::UnlockRequest` and SDK unlock uses `sdk::UnlockRequest`, so unlock flow is not typed against route-layer DTOs. +- `ldk::start_node` accepts `core_types::UnlockRequest` and SDK unlock uses `sdk::UnlockRequest`, so unlock flow is not typed against route-layer DTOs. - A small `routes` diff remains for shared `AppState` transition helpers (`pub(crate)` visibility), used by SDK unlock lifecycle handling. ## E2E and parity harnesses diff --git a/src/uniffi_api/state.rs b/src/uniffi_api/state.rs index 6afe63c7..7d3f2b18 100644 --- a/src/uniffi_api/state.rs +++ b/src/uniffi_api/state.rs @@ -130,6 +130,7 @@ pub(crate) fn map_api_error(err: APIError) -> RlnError { stash_api_error_detail(msg.clone()); match err { APIError::LightningUnsupportedOnMainnet => RlnError::LightningUnsupportedOnMainnet(msg), + APIError::MainnetLightningState(_) => RlnError::MainnetLightningState(msg), APIError::LockedNode | APIError::NotInitialized => RlnError::NotInitialized(msg), APIError::PaymentNotFound(_) | APIError::SwapNotFound(_) diff --git a/src/uniffi_api/tests.rs b/src/uniffi_api/tests.rs index 7c062f71..11ea15b5 100644 --- a/src/uniffi_api/tests.rs +++ b/src/uniffi_api/tests.rs @@ -457,6 +457,19 @@ mod uniffi_smoke_tests { assert!(super::super::state::take_last_api_error_detail().is_none()); } + #[test] + fn uniffi_mainnet_legacy_state_error_preserves_category_and_message() { + let error = crate::error::APIError::MainnetLightningState("local manager snapshot".into()); + let message = error.to_string(); + let mapped = super::super::state::map_api_error(error); + assert!(matches!(mapped, RlnError::MainnetLightningState(_))); + assert_eq!(mapped.to_string(), message); + assert_eq!( + super::super::state::take_last_api_error_detail(), + Some(message) + ); + } + #[test] fn uniffi_errors_preserve_category_and_message() { let err = super::super::state::map_api_error(crate::error::APIError::Unexpected( diff --git a/src/uniffi_api/types.rs b/src/uniffi_api/types.rs index c2a9ad16..0524488e 100644 --- a/src/uniffi_api/types.rs +++ b/src/uniffi_api/types.rs @@ -58,6 +58,8 @@ pub enum RlnError { Internal(String), #[error("{0}")] LightningUnsupportedOnMainnet(String), + #[error("{0}")] + MainnetLightningState(String), } impl RlnError { diff --git a/src/utils.rs b/src/utils.rs index 386240aa..454f4ba6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -167,7 +167,7 @@ pub(crate) struct StaticState { pub(crate) lsp_base_url: Option, pub(crate) lsp_bearer_token: Option, /// VSS server URL (None = VSS disabled). Populated regardless of the - /// `vss` feature flag; only the consumer in `start_ldk` is feature-gated. + /// `vss` feature flag; only the consumer in `start_node` is feature-gated. #[cfg_attr(not(feature = "vss"), allow(dead_code))] pub(crate) vss_url: Option, /// When true, a failed VSS restore on a fresh device logs a warning and @@ -195,13 +195,31 @@ impl StaticState { } } -pub(crate) struct UnlockedAppState { +/// Wallet, signing and persistence state shared by every supported network. +pub(crate) struct CommonState { pub(crate) config: Arc, + pub(crate) signer: ActiveSignerRef, + pub(crate) entropy_source: Arc, + pub(crate) kv_store: Arc, + pub(crate) rgb_wallet_wrapper: Arc, + pub(crate) proxy_endpoint: String, + pub(crate) external_signer_mode: bool, + pub(crate) external_signer: Option>, + pub(crate) external_node_id: Option, + pub(crate) node_id: PublicKey, + pub(crate) indexer_url: String, + #[cfg(feature = "vss")] + pub(crate) persistence_shutdown: CancellationToken, + #[cfg(feature = "vss")] + pub(crate) persistence_worker: Mutex>>, +} + +/// A complete Lightning runtime, present only on supported non-mainnet networks. +pub(crate) struct LightningState { + pub(crate) common: Arc, pub(crate) channel_manager: Arc, pub(crate) gossip_source: Arc, pub(crate) inbound_payments: Arc>, - pub(crate) signer: ActiveSignerRef, - pub(crate) entropy_source: Arc, pub(crate) network_graph: Arc, pub(crate) chain_monitor: Arc, pub(crate) onion_messenger: Arc, @@ -210,27 +228,57 @@ pub(crate) struct UnlockedAppState { pub(crate) asset_link_handler: Arc, pub(crate) async_order_handler: Arc, pub(crate) async_payments_preimage_root: Arc, - pub(crate) kv_store: Arc, #[cfg(feature = "vss")] pub(crate) monitor_kv_store: Arc, pub(crate) rgb_file_transfer_handler: Arc, pub(crate) bump_tx_event_handler: Arc, pub(crate) maker_swaps: Arc>, pub(crate) taker_swaps: Arc>, - pub(crate) rgb_wallet_wrapper: Arc, pub(crate) router: Arc, pub(crate) output_sweeper: Arc, pub(crate) channel_ids_map: Arc>, - pub(crate) proxy_endpoint: String, - pub(crate) external_signer_mode: bool, - pub(crate) external_signer: Option>, - pub(crate) external_node_id: Option, pub(crate) virtual_channel_draft_store: Arc>, pub(crate) virtual_channel_session_store: Arc>, pub(crate) next_payment_idx: Arc, } +pub(crate) struct UnlockedAppState { + pub(crate) common: Arc, + pub(crate) lightning: Option>, +} + +impl std::ops::Deref for UnlockedAppState { + type Target = CommonState; + + fn deref(&self) -> &Self::Target { + &self.common + } +} + +impl std::ops::Deref for LightningState { + type Target = CommonState; + + fn deref(&self) -> &Self::Target { + &self.common + } +} + impl UnlockedAppState { + pub(crate) fn lightning(&self) -> Result<&Arc, APIError> { + self.lightning + .as_ref() + .ok_or_else(|| APIError::Unexpected("Lightning runtime is not available".to_string())) + } +} + +#[cfg(feature = "vss")] +impl Drop for CommonState { + fn drop(&mut self) { + self.persistence_shutdown.cancel(); + } +} + +impl LightningState { pub(crate) fn attach_apay_signatures( &self, mut params: crate::async_order::AsyncOrderNewParamsWire, @@ -376,7 +424,9 @@ impl UnlockedAppState { .map_err(|err| APIError::InvalidRequest(err.message)) } } +} +impl CommonState { pub(crate) fn sign_node_message(&self, message: &[u8]) -> Result { self.signer .sign_message(message) @@ -386,16 +436,11 @@ impl UnlockedAppState { pub(crate) fn runtime_node_pubkey(&self) -> String { self.external_node_id .clone() - .unwrap_or_else(|| self.channel_manager.get_our_node_id().to_string()) + .unwrap_or_else(|| self.node_id.to_string()) } pub(crate) fn runtime_node_id(&self) -> PublicKey { - if let Some(node_id) = &self.external_node_id { - if let Ok(pubkey) = PublicKey::from_str(node_id) { - return pubkey; - } - } - self.channel_manager.get_our_node_id() + self.node_id } } @@ -422,7 +467,7 @@ impl Writeable for UserOnionMessageContents { /// Whether external-signer mode has been configured (a `key_source.json` exists in the storage /// dir). Presence-only by design: the parsed contents are validated where they are actually -/// consumed, inside `start_ldk`. +/// consumed, inside `start_node`. pub(crate) fn is_external_signer_mode_configured(state: &Arc) -> Result { Ok(read_key_source_file(&state.static_state.storage_dir_path) .map_err(|e| APIError::ExternalSignerProtocolError(e.to_string()))? From fd5e5deb0f6379734409ae7d6dfd14238b3fa1f6 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 30 Sep 2026 21:14:18 +0530 Subject: [PATCH 02/14] Keep browser Lightning runtime inactive on mainnet --- bindings/wasm-sdk/README.md | 65 +- bindings/wasm-sdk/src/chain_sync.rs | 45 +- bindings/wasm-sdk/src/ldk_live_backend.rs | 20 +- bindings/wasm-sdk/src/ldk_runtime.rs | 38 + bindings/wasm-sdk/src/ln_node.rs | 1160 ++++++++++++----- bindings/wasm-sdk/src/ln_runtime_native.rs | 2 + bindings/wasm-sdk/src/peer_session.rs | 55 +- bindings/wasm-sdk/src/runtime_store.rs | 60 +- bindings/wasm-sdk/src/sdk_facade.rs | 4 +- .../wasm-sdk/src/tests/ln_node_test_utils.rs | 37 +- bindings/wasm-sdk/src/tests/ln_node_tests.rs | 212 +-- .../src/tests/mainnet_lightning_tests.rs | 455 ++++++- .../wasm-sdk/src/tests/peer_session_tests.rs | 45 + .../wasm-sdk/src/tests/runtime_store_tests.rs | 34 + 14 files changed, 1684 insertions(+), 548 deletions(-) diff --git a/bindings/wasm-sdk/README.md b/bindings/wasm-sdk/README.md index c6ec0e7a..5ef75e8f 100644 --- a/bindings/wasm-sdk/README.md +++ b/bindings/wasm-sdk/README.md @@ -16,31 +16,60 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the consolidated WASM stack overview For endpoint-level status, see [SDK_WASM_ENDPOINT_MATRIX.md](SDK_WASM_ENDPOINT_MATRIX.md). -## Mainnet Lightning restriction +## Mainnet: on-chain only, without a Lightning runtime + +A Mainnet `RlnWasmNode` does not construct an LDK manager, object graph, chain-sync +driver, peer hooks or background Lightning workers. This applies to explicit +`newWithNodeRuntimeId(..., "mainnet")` and to a networkless node that adopts a Mainnet +wallet. Constructing or inspecting a networkless node leaves it dormant. Its first +successful Lightning initialization selects the historical Regtest default; attach +the wallet first when another network is intended. A scope already bound to a +network or identity cannot be reused with a conflicting one. Compatible handles +share the existing runtime without reseeding it. + +Before constructing a Mainnet node or attaching its wallet, call +`await sdk.preloadPersistentRuntimeState()` (SDK `init`/`unlock` already preload). +Construction and adoption check the scope's hydrated local browser storage. Any +protected Lightning snapshot, monitor, queue, sweep, RGB Lightning KV or peer state +refuses with `MainnetLightningState`, without decoding, resuming or deleting it: -A `RlnWasmNode` configured for mainnet rejects Lightning peer (including reconnect -start/resume), channel, invoice, -payment and async-payment operations, including channel funding and manual Lightning -event processing (`chainSyncTick*` included), before starting those operations. The network comes from -`newWithNodeRuntimeId(..., "mainnet")` or the wallet attached to a node without an -explicit network. `RlnWasmSdk` and node-handle wrappers propagate the same error. -The dedicated wallet `buildLightningFundingTx*` methods also reject mainnet. The -peer-manager bridge checks the configured node's hooks before opening a socket; -custom hooks without a configured node retain their existing behavior. +```text +MainnetLightningState: Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: protected browser runtime state is present +``` -The stable error string is: +This conservative check also refuses historical snapshots from a previously +on-chain-only node. It checks the current browser's hydrated IndexedDB/localStorage; +it cannot inspect remote-only `-ldk` VSS recovery state because the +synchronous constructor has no VSS credentials. Review any previous device/remote +Lightning state before using that identity on Mainnet. Independent wallet objects +remain directly usable; this node check is not a process-wide wallet restriction. + +Mainnet rejects peer/connect/reconnect, channel/funding, Lightning invoice/payment, +async-payment and event-processing methods, including `chainSyncTick*`, +`chainSyncStart*`, `chainSyncEnqueueRebroadcastTx`, `installAutoPeerManagerHooks`, +`persistLdkRuntimeState` and `configureLdkVssReplication`. The chain driver combines +manual rebroadcasts with Lightning broadcasts, so its manual enqueue method is also +restricted. Use the on-chain wallet's sync/send methods for on-chain work. The +node-owned bridge rejects before opening a socket. A Mainnet node does not replace +another node's global hooks; standalone transports and custom hooks without a +configured node retain their independent behavior. SDK facades/handles propagate: ```text LightningUnsupportedOnMainnet: RLN on mainnet currently supports only on-chain methods. Lightning APIs are not supported. ``` -On-chain wallet APIs, RGB on-chain invoice decoding, node/network information, -identity signing, lifecycle, runtime status and persistence retain their existing -requirements. Supported non-mainnet networks retain their existing behavior. - -The standalone SDK swap bookkeeping and onion-request validation helpers have no -node/network association and do not execute node Lightning operations. They are -separate from the configured-node APIs covered by this restriction. +On-chain wallet APIs, RGB invoices and message signing retain their requirements and +identity derivation. Shared node/status calls do not start Lightning. An absent +runtime reports `disabled` on Mainnet (`cold` before activation otherwise), no active components +and zero active peers/channels. `chainSyncStop*` is an inactive no-op. Synchronous +`networkInfo*` returns `NetworkInfoUnavailable` when no chain driver exists; it does +not invent a chain height. Query the wallet's on-chain indexer for chain information. +Wallet backups/VSS remain independent of the restricted LDK VSS stream; LDK VSS +health, disable and fence administration remain available. + +Explicit supported non-mainnet constructors retain runtime preparation and saved +chain-driver resumption. Standalone swap bookkeeping and onion-request validation +have no configured node and do not execute node Lightning operations. ## Build diff --git a/bindings/wasm-sdk/src/chain_sync.rs b/bindings/wasm-sdk/src/chain_sync.rs index 5a32972f..f2a8ffc8 100644 --- a/bindings/wasm-sdk/src/chain_sync.rs +++ b/bindings/wasm-sdk/src/chain_sync.rs @@ -111,6 +111,18 @@ struct PendingBroadcastTx { impl WasmChainSyncDriver { pub fn new(runtime_key: String, default_network: String) -> Result { + let driver = Self::new_without_resume(runtime_key, default_network)?; + driver.persist()?; + driver.resume_if_running(); + Ok(driver) + } + + pub(crate) fn new_without_resume( + runtime_key: String, + default_network: String, + ) -> Result { + #[cfg(test)] + crate::ln_node::test_utils::record_startup_call("chain_driver"); let storage_key = format!("{WASM_CHAIN_SYNC_STORAGE_PREFIX}{runtime_key}"); let broadcast_queue_key = format!("{WASM_LDK_BROADCAST_QUEUE_STORAGE_PREFIX}{runtime_key}"); let loaded = load_snapshot(&storage_key)?; @@ -130,13 +142,19 @@ impl WasmChainSyncDriver { state: Rc::new(RefCell::new(snapshot)), loop_active: Rc::new(Cell::new(false)), }; - driver.persist()?; - if driver.is_running() { - driver.ensure_background_loop(); - } Ok(driver) } + pub(crate) fn select_network(&self, network: &str) { + self.state.borrow_mut().network = network.to_string(); + } + + pub(crate) fn resume_if_running(&self) { + if self.is_running() { + self.ensure_background_loop(); + } + } + pub fn status(&self) -> RlnWasmChainSyncStatusData { let snapshot = self.state.borrow(); let rebroadcast_pending = snapshot @@ -165,6 +183,23 @@ impl WasmChainSyncDriver { } } + pub(crate) fn inactive_status(network: String) -> RlnWasmChainSyncStatusData { + RlnWasmChainSyncStatusData { + network, + indexer_url: None, + running: false, + poll_interval_ms: CHAIN_SYNC_DEFAULT_POLL_INTERVAL_MS, + latest_tip_height: None, + last_tip_at: None, + tip_regressed: false, + last_tip_regression_at: None, + last_tick_at: None, + rebroadcast_pending: 0, + rebroadcast_confirmed: 0, + last_error: None, + } + } + pub fn latest_tip_height(&self) -> Option { self.state.borrow().latest_tip_height } @@ -271,6 +306,8 @@ impl WasmChainSyncDriver { if self.loop_active.get() { return; } + #[cfg(test)] + crate::ln_node::test_utils::record_startup_call("chain_task"); self.loop_active.set(true); let driver = self.clone(); spawn_local(async move { diff --git a/bindings/wasm-sdk/src/ldk_live_backend.rs b/bindings/wasm-sdk/src/ldk_live_backend.rs index 860657d6..19a6d628 100644 --- a/bindings/wasm-sdk/src/ldk_live_backend.rs +++ b/bindings/wasm-sdk/src/ldk_live_backend.rs @@ -126,12 +126,12 @@ fn virtual_channels_v0_enabled(runtime_key: &str) -> bool { // // 1. Explicit selection — `RlnWasmNode.newWithNodeRuntimeId(proxy, rid, network)` parses the // network (`"mainnet" | "testnet" | "testnet4" | "signet" | "regtest"`) and calls -// `set_network_for_runtime` at construction, before the object graph exists. `attachWallet` then +// `set_network_for_runtime` during supported-network runtime preparation. `attachWallet` then // validates the wallet's network against it and rejects a mismatch. // 2. Adopt-from-wallet — the bare `RlnWasmNode::new` / SDK-facade path leaves the node unconfigured; // `attach_wallet_shared` reads the attached wallet's network -// (`get_wallet_data().bitcoin_network`), maps it via `rgb_network_to_bitcoin_network`, and calls -// `set_network_for_runtime` (still before the object graph is built). +// (`get_wallet_data().bitcoin_network`). The first Lightning initialization registers that +// network through `set_network_for_runtime`, before building the graph. Mainnet stays cold. // // Consequences: // - The network is captured at object-graph build time and cannot change afterward (a @@ -155,13 +155,19 @@ thread_local! { /// Record the Bitcoin network for a given LDK runtime key. /// /// Must be called before the LDK object graph is first built for that runtime (done automatically -/// from `attach_wallet_shared`, whose wallet carries the configured network). +/// during node runtime preparation, using the configured or adopted wallet network). pub fn set_network_for_runtime(runtime_key: &str, network: bitcoin::Network) { NETWORK_REGISTRY.with(|reg| { reg.borrow_mut().insert(runtime_key.to_string(), network); }); } +pub(crate) fn unregister_node_runtime(runtime_key: &str) { + RGB_WALLET_REGISTRY.with(|registry| registry.borrow_mut().remove(runtime_key)); + NETWORK_REGISTRY.with(|registry| registry.borrow_mut().remove(runtime_key)); + VIRTUAL_CHANNELS_V0_REGISTRY.with(|registry| registry.borrow_mut().remove(runtime_key)); +} + /// The Bitcoin network registered for a given LDK runtime key. /// Defaults to `Regtest` (the historical hardcoded value) when nothing has been registered yet. fn network_for_runtime(runtime_key: &str) -> bitcoin::Network { @@ -1482,7 +1488,13 @@ impl WasmLdkLiveBackend { if self.object_graph.borrow().is_some() { return Ok(()); } + + #[cfg(test)] + crate::ln_node::test_utils::record_startup_call("live_graph"); let network = network_for_runtime(&self.runtime_key); + if network == bitcoin::Network::Bitcoin { + return crate::check_lightning_supported("mainnet"); + } let seed = self.derive_seed32(); let logger = Arc::new(WasmLdkLogger); let fee_estimator = Arc::new(FixedFeeEstimator); diff --git a/bindings/wasm-sdk/src/ldk_runtime.rs b/bindings/wasm-sdk/src/ldk_runtime.rs index 114bff87..77fe019d 100644 --- a/bindings/wasm-sdk/src/ldk_runtime.rs +++ b/bindings/wasm-sdk/src/ldk_runtime.rs @@ -1102,6 +1102,8 @@ impl LdkRuntimeManager for WasmNativeRuntimeManager { } fn set_live_node_seed_hex(&self, seed_hex: String) -> Result<(), JsValue> { + #[cfg(test)] + crate::ln_node::test_utils::record_startup_call("seed_assignment"); let seed_hex = seed_hex.trim(); if seed_hex.is_empty() { return Err(JsValue::from_str("seed_hex cannot be empty")); @@ -1862,6 +1864,8 @@ fn runtime_key_fingerprint(runtime_key: &str) -> String { const WASM_NATIVE_LDK_BACKEND_LABEL: &str = "wasm_native_ldk"; pub fn ldk_runtime_manager(runtime_key: String) -> Result, JsValue> { + #[cfg(test)] + crate::ln_node::test_utils::record_startup_call("manager"); let runtime_key = canonicalize_runtime_key(&runtime_key); let manager_registry_key = runtime_key.clone(); if let Some(manager) = RUNTIME_MANAGER_REGISTRY.with(|registry| { @@ -1885,6 +1889,40 @@ pub fn ldk_runtime_manager(runtime_key: String) -> Result Result, JsValue> { + let key = canonicalize_runtime_key(&runtime_key); + if RUNTIME_MANAGER_REGISTRY.with(|registry| { + registry + .borrow() + .get(&key) + .and_then(Weak::upgrade) + .is_some() + }) { + return Err(JsValue::from_str( + "runtime scope is already owned by another Lightning runtime", + )); + } + ldk_runtime_manager(runtime_key) +} + +pub(crate) fn release_node_runtime_manager(runtime_key: &str, manager: &Rc) { + RUNTIME_MANAGER_REGISTRY.with(|registry| { + let key = canonicalize_runtime_key(runtime_key); + let mut registry = registry.borrow_mut(); + if registry + .get(&key) + .and_then(Weak::upgrade) + .is_some_and(|existing| Rc::ptr_eq(&existing, manager)) + { + registry.remove(&key); + } + }); +} + /// Returns `true` when this was the last live handle for `runtime_key` (the registry /// entry was removed), so per-runtime teardown (e.g. VSS replication guards) may run. pub fn release_runtime_manager_if_last( diff --git a/bindings/wasm-sdk/src/ln_node.rs b/bindings/wasm-sdk/src/ln_node.rs index 3b650354..08b5b52c 100644 --- a/bindings/wasm-sdk/src/ln_node.rs +++ b/bindings/wasm-sdk/src/ln_node.rs @@ -1,7 +1,7 @@ use std::cell::Cell; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; -use std::rc::Rc; +use std::rc::{Rc, Weak}; use std::str::FromStr; use std::time::Duration; @@ -9,6 +9,7 @@ use bitcoin_hashes::sha256::Hash as Sha256; use bitcoin_hashes::Hash as _; #[cfg(target_arch = "wasm32")] use gloo_net::http::Request; +use lightning::bitcoin; use lightning_invoice::Bolt11Invoice; use lightning_invoice::Currency; use lightning_invoice::InvoiceBuilder; @@ -33,8 +34,8 @@ use crate::ldk_runtime::{ use crate::ln_runtime_native::{NativeLnRuntimeCore, NativeLnRuntimeCoreStatusData}; use crate::ln_transport::RlnWasmLnSocketConnectOptionsData; use crate::peer_session::{ - clear_rln_ldk_peer_manager_hooks, has_peer_manager_hooks, has_peer_manager_hooks_v2, - RlnLdkPeerManagerHooks, RlnWasmPeerSession, RlnWasmRustPeerManagerBridge, + clear_rln_ldk_peer_manager_hooks, RlnLdkPeerManagerHooks, RlnWasmPeerSession, + RlnWasmRustPeerManagerBridge, }; use crate::runtime_store::{browser_persistent_state_store, RuntimeStateStore}; use crate::wasm_node_persistence::{JsonRuntimeStateStore, RuntimeScopeKeys}; @@ -372,15 +373,60 @@ const AUTO_DRIVE_MIN_INTERVAL_MS: u32 = 200; #[path = "tests/ln_node_test_utils.rs"] pub(crate) mod test_utils; +struct NodeLightningRuntime { + runtime_key: String, + vss_owned: Rc>, + ldk_runtime: Rc, + runtime_core: NativeLnRuntimeCore, + chain_sync: WasmChainSyncDriver, +} + +impl Drop for NodeLightningRuntime { + fn drop(&mut self) { + let _ = self.chain_sync.stop(); + let _ = self.ldk_runtime.stop(); + self.runtime_core.stop(); + crate::ldk_runtime::release_node_runtime_manager(&self.runtime_key, &self.ldk_runtime); + crate::ldk_live_backend::unregister_node_runtime(&self.runtime_key); + if self.vss_owned.replace(false) { + crate::vss_replicator::teardown_vss_replication(&self.runtime_key); + } + } +} + +struct NodeRuntimeScope { + network: Rc>, + identity_seed: [u8; 32], + lightning: Rc>>>, + wallet_identity: RefCell>, + identity_wallet: RefCell>>>, + network_transition: Cell, + vss_owned: Rc>, +} + +struct NodeNetworkTransition(Rc); + +impl Drop for NodeNetworkTransition { + fn drop(&mut self) { + self.0.network_transition.set(false); + } +} + +thread_local! { + static NODE_RUNTIME_SCOPES: RefCell>> = + RefCell::new(HashMap::new()); +} + #[wasm_bindgen] pub struct RlnWasmNode { proxy_url: String, node_runtime_id: Option, persistence_keys: RuntimeScopeKeys, bridge: RlnWasmRustPeerManagerBridge, - ldk_runtime: Rc, - runtime_core: NativeLnRuntimeCore, - chain_sync: WasmChainSyncDriver, + runtime_scope: Rc, + lightning: Rc>>>, + live_node_seed: [u8; 32], + auto_hooks_installed: Cell, peers: Rc>>, channels: Rc>>, payments: Rc>>, @@ -395,10 +441,6 @@ pub struct RlnWasmNode { /// Authoritative configured/adopted network for API policy. Keep this separate from mutable /// chain-sync diagnostics, and readable while an async on-chain operation borrows the wallet. configured_network: Rc>, - /// Whether the network was explicitly selected at construction (native-style `--network`). - /// When `true`, `attach_wallet_shared` validates the wallet's network against it and errors on - /// mismatch. When `false` (bare `new`/facade path), the node adopts the attached wallet's network. - network_configured: Cell, wallet: RefCell>>>, relay_session_auth: RefCell>, enable_virtual_channels_v0: RefCell, @@ -422,16 +464,99 @@ impl RlnWasmNode { crate::check_lightning_supported(&self.configured_network.borrow()) } + fn prepare_lightning_runtime(&self, start: bool) -> Result<(), JsValue> { + self.check_lightning_supported()?; + if self.runtime_scope.network_transition.get() { + return Err(JsValue::from_str( + "runtime network selection is in progress", + )); + } + if self.lightning.borrow().is_none() { + let network = self.configured_network.borrow().clone(); + let resolved = if network == "unknown" { + "regtest" + } else { + &network + }; + let selected = crate::WasmRlnNetwork::parse(resolved)?.as_rgb(); + let runtime_key = self.runtime_manager_key(); + let ldk_runtime = crate::ldk_runtime::claim_node_runtime_manager(runtime_key.clone())?; + ldk_runtime.set_live_node_seed_hex(hex::encode(self.live_node_seed))?; + // Prepare locally before registering any backend or resuming saved workers. + let chain_sync = + WasmChainSyncDriver::new_without_resume(runtime_key.clone(), resolved.to_string())?; + chain_sync.select_network(resolved); + let runtime_core = NativeLnRuntimeCore::new(runtime_key.clone()); + if start { + ldk_runtime.ensure_started()?; + } + crate::ldk_live_backend::set_network_for_runtime( + &runtime_key, + crate::ldk_live_backend::rgb_network_to_bitcoin_network(selected), + ); + if let Some(wallet) = self.wallet.borrow().as_ref() { + crate::ldk_live_backend::register_rgb_wallet_for_runtime( + &runtime_key, + Rc::clone(wallet), + ); + } + crate::ldk_live_backend::set_virtual_channels_v0_for_runtime( + &runtime_key, + *self.enable_virtual_channels_v0.borrow(), + ); + self.lightning.replace(Some(Rc::new(NodeLightningRuntime { + vss_owned: Rc::clone(&self.runtime_scope.vss_owned), + runtime_key, + ldk_runtime, + runtime_core, + chain_sync, + }))); + KNOWN_RUNTIME_SCOPE_KEYS.with(|keys| { + keys.borrow_mut() + .insert(self.persistence_keys.runtime_scope_key.clone()); + }); + // A failed preparation never commits the legacy Regtest fallback. + *self.configured_network.borrow_mut() = resolved.to_string(); + *self.network.borrow_mut() = resolved.to_string(); + } + Ok(()) + } + fn ensure_runtime_ready(&self) -> Result<(), JsValue> { crate::ensure_sdk_node_runtime_allowed()?; - self.runtime_core.ensure_started(); - self.ldk_runtime.ensure_started()?; - self.ldk_runtime.virtual_channel_reconcile_sessions(); - self.ldk_runtime + self.prepare_lightning_runtime(true)?; + let runtime = self.lightning_runtime()?; + runtime.ldk_runtime.ensure_started()?; + runtime.runtime_core.ensure_started(); + runtime.ldk_runtime.virtual_channel_reconcile_sessions(); + runtime + .ldk_runtime .set_identity_stable(self.identity_stable_for_channel_operations()); + if !self.auto_hooks_installed.get() { + self.install_auto_peer_manager_hooks_inner(); + } + self.register_runtime_scope_for_local_pubkey(); + runtime.chain_sync.resume_if_running(); Ok(()) } + fn inactive_runtime_state(&self) -> &'static str { + if self.configured_network.borrow().as_str() == "mainnet" { + "disabled" + } else { + "cold" + } + } + + fn lightning_runtime(&self) -> Result, JsValue> { + self.check_lightning_supported()?; + self.lightning + .borrow() + .as_ref() + .cloned() + .ok_or_else(|| JsValue::from_str("Lightning runtime is not initialized")) + } + fn has_stable_runtime_id(&self) -> bool { self.node_runtime_id .as_deref() @@ -510,9 +635,63 @@ impl RlnWasmNode { let runtime_scope_key = runtime_scope_key(proxy_url.trim(), normalized_runtime_id.as_deref()); let persistence_keys = RuntimeScopeKeys::from_runtime_scope_key(runtime_scope_key.clone()); - KNOWN_RUNTIME_SCOPE_KEYS.with(|keys| { - keys.borrow_mut().insert(runtime_scope_key.clone()); - }); + let network_label = network + .map(|n| rgb_network_label(n.as_rgb())) + .unwrap_or("unknown"); + if network_label == "mainnet" { + crate::runtime_store::check_mainnet_runtime_state(&persistence_keys)?; + } + let live_node_seed = + derive_node_signing_identity(&proxy_url, normalized_runtime_id.as_deref())? + .0 + .secret_bytes(); + let runtime_scope = NODE_RUNTIME_SCOPES.with(|registry| { + let mut registry = registry.borrow_mut(); + registry.retain(|_, scope| scope.strong_count() > 0); + if let Some(scope) = registry + .get(&persistence_keys.ldk_manager_registry_key) + .and_then(Weak::upgrade) + { + if scope.network_transition.get() { + return Err(JsValue::from_str( + "runtime network selection is in progress", + )); + } + if scope.identity_seed != live_node_seed { + return Err(JsValue::from_str( + "runtime scope already uses a different node identity", + )); + } + let previous = scope.network.borrow().clone(); + if previous == "mainnet" && network_label == "unknown" { + crate::runtime_store::check_mainnet_runtime_state(&persistence_keys)?; + } + if previous != "unknown" && network_label != "unknown" && previous != network_label + { + return Err(JsValue::from_str( + "runtime scope already uses a different Bitcoin network", + )); + } + if previous == "unknown" && network_label != "unknown" { + *scope.network.borrow_mut() = network_label.to_string(); + } + return Ok(scope); + } + let scope = Rc::new(NodeRuntimeScope { + network: Rc::new(RefCell::new(network_label.to_string())), + identity_seed: live_node_seed, + lightning: Rc::new(RefCell::new(None)), + wallet_identity: RefCell::new(None), + identity_wallet: RefCell::new(None), + network_transition: Cell::new(false), + vss_owned: Rc::new(Cell::new(false)), + }); + registry.insert( + persistence_keys.ldk_manager_registry_key.clone(), + Rc::downgrade(&scope), + ); + Ok(scope) + })?; let runtime_event_snapshot = load_runtime_event_log_snapshot(&persistence_keys.runtime_events_storage_key); let runtime_events = runtime_event_snapshot @@ -535,34 +714,16 @@ impl RlnWasmNode { .collect::>() }) .unwrap_or_default(); - let ldk_runtime = crate::ldk_runtime::ldk_runtime_manager( - persistence_keys.ldk_manager_registry_key.clone(), - )?; - let runtime_core = - NativeLnRuntimeCore::new(persistence_keys.ldk_manager_registry_key.clone()); - // When a network is explicitly selected it becomes the node's single source of truth (like - // the native node's `--network`); otherwise fall back to the historical `regtest` default and - // let the first attached wallet supply the network. - let configured_rgb_network = network.map(|n| n.as_rgb()); - let default_network_label = configured_rgb_network - .map(rgb_network_label) - .unwrap_or("regtest"); - let chain_sync = WasmChainSyncDriver::new( - persistence_keys.ldk_manager_registry_key.clone(), - default_network_label.to_string(), - )?; - let restored_network = if configured_rgb_network.is_some() { - default_network_label.to_string() - } else { - chain_sync.status().network - }; + let restored_network = runtime_scope.network.borrow().clone(); let enable_virtual_channels_v0 = load_virtual_channels_v0_flag(&persistence_keys.virtual_channels_v0_storage_key) .unwrap_or_else(crate::sdk_default_enable_virtual_channels_v0); let node = Self { - ldk_runtime, - runtime_core, - chain_sync, + lightning: Rc::clone(&runtime_scope.lightning), + configured_network: Rc::clone(&runtime_scope.network), + runtime_scope, + live_node_seed, + auto_hooks_installed: Cell::new(false), proxy_url, node_runtime_id: normalized_runtime_id, persistence_keys, @@ -577,9 +738,7 @@ impl RlnWasmNode { next_payment_seq: RefCell::new(0), node_instance_nonce: Self::next_node_instance_nonce(), next_runtime_event_seq: Rc::new(RefCell::new(next_runtime_event_seq)), - configured_network: Rc::new(RefCell::new(restored_network.clone())), network: RefCell::new(restored_network), - network_configured: Cell::new(configured_rgb_network.is_some()), wallet: RefCell::new(None), relay_session_auth: RefCell::new(None), enable_virtual_channels_v0: RefCell::new(enable_virtual_channels_v0), @@ -588,25 +747,16 @@ impl RlnWasmNode { auto_drive_running: Rc::new(RefCell::new(false)), auto_drive_interval_ms: Rc::new(RefCell::new(AUTO_DRIVE_DEFAULT_INTERVAL_MS)), }; - // For an explicitly-selected network, register it with the LDK backend now — before the - // object graph (ChannelManager/NetworkGraph) is first built — so the handshake advertises the - // configured chain even if a wallet is never attached. - if let Some(rgb_network) = configured_rgb_network { - crate::ldk_live_backend::set_network_for_runtime( - &node.persistence_keys.ldk_manager_registry_key, - crate::ldk_live_backend::rgb_network_to_bitcoin_network(rgb_network), - ); + let configured_network = Rc::clone(&node.configured_network); + node.bridge.set_node_policy(Rc::new(move || { + crate::check_lightning_supported(&configured_network.borrow()) + })); + if network.is_some() && network_label != "mainnet" { + node.prepare_lightning_runtime(false)?; + node.install_auto_peer_manager_hooks_inner(); + node.register_runtime_scope_for_local_pubkey(); + node.lightning_runtime()?.chain_sync.resume_if_running(); } - // Keep live LDK backend identity aligned with node_signing_identity pubkey. - let (node_secret_key, _) = node.node_signing_identity()?; - node.ldk_runtime - .set_live_node_seed_hex(hex::encode(node_secret_key.secret_bytes()))?; - node.ldk_runtime - .set_identity_stable(node.identity_stable_for_channel_operations()); - // Native-only interop default: wire real runtime peer-manager hooks on node creation, - // so connectPeer/openChannel never depends on scaffold bridge callbacks. - node.install_auto_peer_manager_hooks(); - node.register_runtime_scope_for_local_pubkey(); Ok(node) } @@ -635,46 +785,57 @@ impl RlnWasmNode { &self, wallet: Rc>, ) -> Result<(), JsValue> { - // Reconcile the wallet's network with the node's. - // - // - Network selected explicitly at construction (`network_configured`): the node owns the - // network (like the native `--network`), so the wallet MUST match. A mismatch is a - // configuration error and is rejected up front — mirroring the native node's - // `NetworkMismatch`, and preventing the LDK side from advertising a chain the wallet can't - // actually operate on. - // - Otherwise: adopt the wallet's network as the node's, and propagate it to the LDK backend - // (so the `ChannelManager`/`NetworkGraph`, and thus the `networks` field of the `Init` - // handshake, advertise the right chain) and to the node's own network string (invoice - // currency, chain-sync status). - let bitcoin_network = wallet.borrow().get_wallet_data().bitcoin_network; - let wallet_label = rgb_network_label(bitcoin_network); - if self.network_configured.get() { - let node_label = self.network.borrow().clone(); - if wallet_label != node_label { - return Err(JsValue::from_str(&format!( - "wallet network ({wallet_label}) does not match the node's configured network ({node_label})" - ))); - } - } else { - *self.network.borrow_mut() = wallet_label.to_string(); - *self.configured_network.borrow_mut() = wallet_label.to_string(); - let _ = self.chain_sync.set_network(wallet_label); + if self.runtime_scope.network_transition.get() { + return Err(JsValue::from_str( + "runtime network selection is in progress", + )); } - crate::ldk_live_backend::set_network_for_runtime( - &self.persistence_keys.ldk_manager_registry_key, - crate::ldk_live_backend::rgb_network_to_bitcoin_network(bitcoin_network), - ); - crate::ldk_live_backend::register_rgb_wallet_for_runtime( - &self.persistence_keys.ldk_manager_registry_key, - Rc::clone(&wallet), - ); - // Seed the live-backend virtual-channels flag registry with this node's current value - // (default/persisted) before the LDK object graph is first built, so the - // `Event::OpenChannelRequest` handler sees the right gate even if the setter is never called. - crate::ldk_live_backend::set_virtual_channels_v0_for_runtime( - &self.persistence_keys.ldk_manager_registry_key, - *self.enable_virtual_channels_v0.borrow(), + let wallet_data = wallet + .try_borrow() + .map_err(|_| { + JsValue::from_str("RGB wallet is busy; retry after the current operation") + })? + .get_wallet_data(); + let wallet_identity = format!( + "{}:{}:{}:{}", + wallet_data.master_fingerprint, + wallet_data.account_xpub_vanilla, + wallet_data.account_xpub_colored, + wallet_data.vanilla_keychain.unwrap_or(0) ); + let bitcoin_network = wallet_data.bitcoin_network; + let wallet_label = rgb_network_label(bitcoin_network); + let previous = self.configured_network.borrow().clone(); + if previous != "unknown" && previous != wallet_label { + return Err(JsValue::from_str(&format!( + "wallet network ({wallet_label}) does not match the node's configured network ({previous})" + ))); + } + if self + .runtime_scope + .wallet_identity + .borrow() + .as_ref() + .is_some_and(|identity| identity != &wallet_identity) + { + return Err(JsValue::from_str( + "runtime scope already uses a different wallet identity", + )); + } + if wallet_label == "mainnet" { + crate::runtime_store::check_mainnet_runtime_state(&self.persistence_keys)?; + } + // All validation precedes policy, wallet and global registry changes. + *self.configured_network.borrow_mut() = wallet_label.to_string(); + *self.network.borrow_mut() = wallet_label.to_string(); + *self.runtime_scope.wallet_identity.borrow_mut() = Some(wallet_identity); + *self.runtime_scope.identity_wallet.borrow_mut() = Some(Rc::clone(&wallet)); + if let Some(runtime) = self.lightning.borrow().as_ref() { + crate::ldk_live_backend::register_rgb_wallet_for_runtime( + &runtime.runtime_key, + Rc::clone(&wallet), + ); + } *self.wallet.borrow_mut() = Some(wallet); Ok(()) } @@ -744,10 +905,12 @@ impl RlnWasmNode { // Mirror the flag into the live-backend registry so the `Event::OpenChannelRequest` handler // (which decides whether to accept inbound scid-privacy channels as 0-conf virtual channels) // can read it by runtime key. - crate::ldk_live_backend::set_virtual_channels_v0_for_runtime( - &self.persistence_keys.ldk_manager_registry_key, - enabled, - ); + if self.lightning.borrow().is_some() { + crate::ldk_live_backend::set_virtual_channels_v0_for_runtime( + &self.persistence_keys.ldk_manager_registry_key, + enabled, + ); + } } #[wasm_bindgen(js_name = enableVirtualChannelsV0Value)] @@ -766,7 +929,7 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = issueAssetNiaValue)] pub fn issue_asset_nia_value(&self, request_js: JsValue) -> Result { - self.ensure_runtime_ready()?; + crate::ensure_sdk_node_runtime_allowed()?; let request: WasmIssueAssetNiaRequest = serde_wasm_bindgen::from_value(request_js) .map_err(|e| JsValue::from_str(&format!("Invalid issue_asset_nia request: {e}")))?; if request.amounts.is_empty() { @@ -800,7 +963,7 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = issueAssetCfaValue)] pub fn issue_asset_cfa_value(&self, request_js: JsValue) -> Result { - self.ensure_runtime_ready()?; + crate::ensure_sdk_node_runtime_allowed()?; let request: WasmIssueAssetCfaRequest = serde_wasm_bindgen::from_value(request_js) .map_err(|e| JsValue::from_str(&format!("Invalid issue_asset_cfa request: {e}")))?; if request.amounts.is_empty() { @@ -845,7 +1008,7 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = issueAssetIfaValue)] pub fn issue_asset_ifa_value(&self, request_js: JsValue) -> Result { - self.ensure_runtime_ready()?; + crate::ensure_sdk_node_runtime_allowed()?; let request: WasmIssueAssetIfaRequest = serde_wasm_bindgen::from_value(request_js) .map_err(|e| JsValue::from_str(&format!("Invalid issue_asset_ifa request: {e}")))?; if request.amounts.is_empty() { @@ -904,12 +1067,13 @@ impl RlnWasmNode { if SecpPublicKey::from_str(peer_pubkey.trim()).is_err() { return Err(JsValue::from_str(sdk_contracts::ERR_PEER_PUBKEY_INVALID)); } - if !has_peer_manager_hooks() { + let (hooks_available, hooks_v2_ready) = self.bridge.connection_hooks_ready()?; + if !hooks_available { return Err(JsValue::from_str( "peer-manager hooks are not installed; install real peer-manager hooks before connectPeer", )); } - if !has_peer_manager_hooks_v2() { + if !hooks_v2_ready { return Err(JsValue::from_str( "peer-manager hooks are not V2-ready; installPeerManagerHooksFromJsV2 with take_outbound_frames is required", )); @@ -933,9 +1097,13 @@ impl RlnWasmNode { self.peers.borrow_mut().remove(&peer_pubkey); if self.use_runtime_state_for_ln_views() { let _ = self + .lightning_runtime()? .ldk_runtime .peer_socket_disconnected_for_peer(&peer_pubkey); - let _ = self.ldk_runtime.remove_peer(&peer_pubkey); + let _ = self + .lightning_runtime()? + .ldk_runtime + .remove_peer(&peer_pubkey); } if !self.use_runtime_state_for_ln_views() && self.peers.borrow().contains_key(&peer_pubkey) @@ -991,11 +1159,12 @@ impl RlnWasmNode { let mut handshake_complete = false; for _ in 0..300 { if self + .lightning_runtime()? .ldk_runtime .peer_is_handshake_complete(&peer_pubkey) .unwrap_or(false) { - let _ = self.ldk_runtime.peer_process_events(); + let _ = self.lightning_runtime()?.ldk_runtime.peer_process_events(); handshake_complete = true; break; } @@ -1019,17 +1188,22 @@ impl RlnWasmNode { ); self.persist_peer_session_state(); if self.use_runtime_state_for_ln_views() { - let runtime_connected = self.ldk_runtime.has_connected_peer(&peer_pubkey); - self.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { - pubkey: peer_pubkey.clone(), - peer_addr: self - .peers - .borrow() - .get(&peer_pubkey) - .map(|entry| entry.peer_addr.clone()) - .unwrap_or_default(), - started: runtime_connected, - }); + let runtime_connected = self + .lightning_runtime()? + .ldk_runtime + .has_connected_peer(&peer_pubkey); + self.lightning_runtime()? + .ldk_runtime + .upsert_peer(LdkRuntimePeerStateData { + pubkey: peer_pubkey.clone(), + peer_addr: self + .peers + .borrow() + .get(&peer_pubkey) + .map(|entry| entry.peer_addr.clone()) + .unwrap_or_default(), + started: runtime_connected, + }); } let applied = self .apply_and_record_transport_event( @@ -1048,7 +1222,7 @@ impl RlnWasmNode { "[rln-wasm-sdk connectPeer] done peer_pubkey={} peers={} runtime_peers={}", peer_pubkey, self.peers.borrow().len(), - self.ldk_runtime.list_peers().len() + self.lightning_runtime()?.ldk_runtime.list_peers().len() )); self.persist_runtime_event_log_state(); Ok(()) @@ -1074,7 +1248,7 @@ impl RlnWasmNode { if let Some(session) = session { session.close().await?; } else if !(self.use_runtime_state_for_ln_views() - && self.ldk_runtime.has_peer(&peer_pubkey)) + && self.lightning_runtime()?.ldk_runtime.has_peer(&peer_pubkey)) { return Err(JsValue::from_str(sdk_contracts::ERR_PEER_NOT_CONNECTED)); } @@ -1150,7 +1324,7 @@ impl RlnWasmNode { let peer_session_store_key = self.persistence_keys.peer_sessions_storage_key.clone(); let relay_session_auth = self.relay_session_auth.borrow().clone(); let peers = Rc::clone(&self.peers); - let ldk_runtime = Rc::clone(&self.ldk_runtime); + let ldk_runtime = Rc::clone(&self.lightning_runtime()?.ldk_runtime); let running = Rc::clone(&self.reconnect_manager_running); let backoff_ms = Rc::clone(&self.reconnect_manager_backoff_ms); let bridge = self.bridge.clone(); @@ -1253,9 +1427,9 @@ impl RlnWasmNode { *self.auto_drive_running.borrow_mut() = true; *self.auto_drive_interval_ms.borrow_mut() = interval; - let chain_sync = self.chain_sync.clone(); - let ldk_runtime = Rc::clone(&self.ldk_runtime); - let runtime_core = self.runtime_core.clone(); + let chain_sync = self.lightning_runtime()?.chain_sync.clone(); + let ldk_runtime = Rc::clone(&self.lightning_runtime()?.ldk_runtime); + let runtime_core = self.lightning_runtime()?.runtime_core.clone(); let peers = Rc::clone(&self.peers); let channels = Rc::clone(&self.channels); let payments = Rc::clone(&self.payments); @@ -1346,7 +1520,7 @@ impl RlnWasmNode { let peer_session_store_key = self.persistence_keys.peer_sessions_storage_key.clone(); let relay_session_auth = self.relay_session_auth.borrow().clone(); let peers = Rc::clone(&self.peers); - let ldk_runtime = Rc::clone(&self.ldk_runtime); + let ldk_runtime = Rc::clone(&self.lightning_runtime()?.ldk_runtime); let backoff_ms = Rc::clone(&self.reconnect_manager_backoff_ms); let bridge = self.bridge.clone(); spawn_local(async move { @@ -1372,7 +1546,8 @@ impl RlnWasmNode { self.check_lightning_supported()?; self.ensure_runtime_ready()?; let mut data = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .list_peers() .into_iter() .map(|peer| RlnWasmNodePeerData { @@ -1409,6 +1584,7 @@ impl RlnWasmNode { self.ensure_runtime_ready()?; let mut data = if self.use_runtime_state_for_ln_views() { let mut runtime = self + .lightning_runtime()? .ldk_runtime .list_channels() .into_iter() @@ -1417,8 +1593,12 @@ impl RlnWasmNode { // Listing must not be a destructive poll. Reconcile is driven by open/funding, // peer processing, and chain-sync ticks; only try it here when runtime is empty. if runtime.is_empty() { - let _ = self.ldk_runtime.reconcile_channels_from_live(); + let _ = self + .lightning_runtime()? + .ldk_runtime + .reconcile_channels_from_live(); runtime = self + .lightning_runtime()? .ldk_runtime .list_channels() .into_iter() @@ -1436,6 +1616,7 @@ impl RlnWasmNode { for channel in &mut data { if channel.virtual_open_mode.is_none() && self + .lightning_runtime()? .ldk_runtime .virtual_channel_session_get(&channel.channel_id) .is_some() @@ -1457,37 +1638,29 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = nodeInfoValue)] pub fn node_info_value(&self) -> Result { - self.ensure_runtime_ready()?; - let (num_channels, num_usable_channels) = if self.use_runtime_state_for_ln_views() { - let channels = self.ldk_runtime.list_channels(); - let num_channels = channels.len(); - let num_usable_channels = channels.iter().filter(|entry| entry.is_usable).count(); - (num_channels, num_usable_channels) + crate::ensure_sdk_node_runtime_allowed()?; + let runtime = self.lightning.borrow(); + let (runtime_label, peers, channels) = if let Some(runtime) = runtime.as_ref() { + let status = runtime.ldk_runtime.status(); + ( + format!("{}:{}", status.backend, status.lifecycle_state), + runtime.ldk_runtime.list_peers().len(), + runtime.ldk_runtime.list_channels(), + ) } else { - let channels = self.channels.borrow(); - let num_channels = channels.len(); - let num_usable_channels = channels - .values() - .filter(|entry| entry.data.is_usable) - .count(); - (num_channels, num_usable_channels) - }; - let runtime_status = self.ldk_runtime.status(); - let data = RlnWasmNodeInfoData { - runtime: format!( - "wasm32-unknown-unknown/{}:{}", - runtime_status.backend, runtime_status.lifecycle_state - ), - ldk_over_websocket: true, - num_peers: if self.use_runtime_state_for_ln_views() { - self.ldk_runtime.list_peers().len() - } else { - self.peers.borrow().len() - }, - num_channels, - num_usable_channels, + ( + format!("wasm_native_ldk:{}", self.inactive_runtime_state()), + 0, + Vec::new(), + ) }; - crate::js_obj(&data) + crate::js_obj(&RlnWasmNodeInfoData { + runtime: format!("wasm32-unknown-unknown/{runtime_label}"), + ldk_over_websocket: runtime.is_some(), + num_peers: peers, + num_channels: channels.len(), + num_usable_channels: channels.iter().filter(|ch| ch.is_usable).count(), + }) } #[wasm_bindgen(js_name = nodeInfoJson)] @@ -1499,7 +1672,7 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = nodePubkeyValue)] pub fn node_pubkey_value(&self) -> Result { - self.ensure_runtime_ready()?; + crate::ensure_sdk_node_runtime_allowed()?; let pubkey = self .local_node_pubkey_string() .ok_or_else(|| JsValue::from_str(sdk_contracts::ERR_NODE_PUBKEY_DERIVE_FAILED))?; @@ -1515,10 +1688,13 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = networkInfoValue)] pub fn network_info_value(&self) -> Result { - self.ensure_runtime_ready()?; + crate::ensure_sdk_node_runtime_allowed()?; + let runtime = self.lightning.borrow(); + let runtime = runtime.as_ref().ok_or_else(|| JsValue::from_str( + "NetworkInfoUnavailable: no chain height is available without an initialized Lightning chain driver; use the on-chain wallet indexer"))?; crate::js_obj(&RlnWasmNodeNetworkInfoData { - network: self.network.borrow().clone(), - height: self.chain_sync.latest_tip_height().unwrap_or(0), + network: self.configured_network.borrow().clone(), + height: runtime.chain_sync.latest_tip_height().unwrap_or(0), }) } @@ -1529,8 +1705,10 @@ impl RlnWasmNode { poll_interval_ms: Option, ) -> Result { self.ensure_runtime_ready()?; - self.chain_sync.start(indexer_url, poll_interval_ms)?; - let status: RlnWasmChainSyncStatusData = self.chain_sync.status(); + self.lightning_runtime()? + .chain_sync + .start(indexer_url, poll_interval_ms)?; + let status: RlnWasmChainSyncStatusData = self.lightning_runtime()?.chain_sync.status(); *self.network.borrow_mut() = status.network.clone(); crate::js_obj(&status) } @@ -1548,9 +1726,11 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = chainSyncStopValue)] pub fn chain_sync_stop_value(&self) -> Result { - self.ensure_runtime_ready()?; - self.chain_sync.stop()?; - crate::js_obj(&self.chain_sync.status()) + crate::ensure_sdk_node_runtime_allowed()?; + if let Some(runtime) = self.lightning.borrow().as_ref() { + runtime.chain_sync.stop()?; + } + self.chain_sync_status_value() } #[wasm_bindgen(js_name = chainSyncStopJson)] @@ -1562,8 +1742,16 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = chainSyncStatusValue)] pub fn chain_sync_status_value(&self) -> Result { - self.ensure_runtime_ready()?; - crate::js_obj(&self.chain_sync.status()) + crate::ensure_sdk_node_runtime_allowed()?; + let status = self + .lightning + .borrow() + .as_ref() + .map(|runtime| runtime.chain_sync.status()) + .unwrap_or_else(|| { + WasmChainSyncDriver::inactive_status(self.configured_network.borrow().clone()) + }); + crate::js_obj(&status) } #[wasm_bindgen(js_name = chainSyncStatusJson)] @@ -1581,9 +1769,9 @@ impl RlnWasmNode { // The autonomous loop (`autoDriveStart`) runs the exact same `node_drive_tick_once`, so a // manually-ticked and a self-driven node progress identically. node_drive_tick_once( - &self.chain_sync, - &self.ldk_runtime, - &self.runtime_core, + &self.lightning_runtime()?.chain_sync, + &self.lightning_runtime()?.ldk_runtime, + &self.lightning_runtime()?.runtime_core, &self.peers, &self.channels, &self.payments, @@ -1594,7 +1782,7 @@ impl RlnWasmNode { "chain_sync_tick", ) .await?; - crate::js_obj(&self.chain_sync.status()) + crate::js_obj(&self.lightning_runtime()?.chain_sync.status()) } #[wasm_bindgen(js_name = chainSyncTickJson)] @@ -1611,14 +1799,30 @@ impl RlnWasmNode { tx_hex: String, ) -> Result<(), JsValue> { self.ensure_runtime_ready()?; - self.chain_sync.enqueue_rebroadcast_tx(txid, tx_hex) + self.lightning_runtime()? + .chain_sync + .enqueue_rebroadcast_tx(txid, tx_hex) } #[wasm_bindgen(js_name = ldkRuntimeStatusValue)] pub fn ldk_runtime_status_value(&self) -> Result { - self.ldk_runtime - .set_identity_stable(self.identity_stable_for_channel_operations()); - let status: LdkRuntimeStatusData = self.ldk_runtime.status(); + let status = if let Some(runtime) = self.lightning.borrow().as_ref() { + runtime + .ldk_runtime + .set_identity_stable(self.identity_stable_for_channel_operations()); + runtime.ldk_runtime.status() + } else { + LdkRuntimeStatusData { + backend: "wasm_native_ldk".to_string(), + lifecycle_state: self.inactive_runtime_state().to_string(), + ready: false, + identity_stable: self.identity_stable_for_channel_operations(), + channel_manager_restored: false, + monitors_restored: false, + storage_initialized: false, + schema_version: 1, + } + }; crate::js_obj(&status) } @@ -1631,8 +1835,29 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = ldkRuntimeComponentsValue)] pub fn ldk_runtime_components_value(&self) -> Result { - self.ensure_runtime_ready()?; - let status: LdkRuntimeComponentsStatusData = self.ldk_runtime.component_status(); + crate::ensure_sdk_node_runtime_allowed()?; + let status = self + .lightning + .borrow() + .as_ref() + .map(|runtime| runtime.ldk_runtime.component_status()) + .unwrap_or_else(|| LdkRuntimeComponentsStatusData { + backend: "wasm_native_ldk".to_string(), + started: false, + fee_estimator_ready: false, + broadcaster_ready: false, + logger_ready: false, + persister_ready: false, + key_manager_ready: false, + payment_engine_ready: false, + channel_engine_ready: false, + key_manager_fingerprint: String::new(), + invoices_created: 0, + payments_initiated: 0, + keysends_initiated: 0, + channels_opened: 0, + channels_closed: 0, + }); crate::js_obj(&status) } @@ -1645,7 +1870,10 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = persistLdkRuntimeState)] pub fn persist_ldk_runtime_state(&self) -> Result<(), JsValue> { - self.ldk_runtime.persist_live_state()?; + self.check_lightning_supported()?; + if let Some(runtime) = self.lightning.borrow().as_ref() { + runtime.ldk_runtime.persist_live_state()?; + } Ok(()) } @@ -1653,7 +1881,12 @@ impl RlnWasmNode { pub fn list_pending_funding_requests_value(&self) -> Result { self.check_lightning_supported()?; self.ensure_runtime_ready()?; - crate::js_obj(&self.ldk_runtime.list_pending_funding_requests()?) + crate::js_obj( + &self + .lightning_runtime()? + .ldk_runtime + .list_pending_funding_requests()?, + ) } #[wasm_bindgen(js_name = listPendingFundingRequestsJson)] @@ -1681,7 +1914,8 @@ impl RlnWasmNode { "temporary_channel_id, counterparty_node_id and funding_tx_hex are required", )); } - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .submit_funding_transaction(LdkRuntimeFundingTxSubmissionData { temporary_channel_id: submission.temporary_channel_id, counterparty_node_id: submission.counterparty_node_id, @@ -1723,7 +1957,7 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = signMessageValue)] pub fn sign_message_value(&self, message: String) -> Result { - self.ensure_runtime_ready()?; + crate::ensure_sdk_node_runtime_allowed()?; let signed_message = self.sign_node_message(message.trim())?; crate::js_obj(&RlnWasmNodeSignMessageData { signed_message }) } @@ -1740,7 +1974,8 @@ impl RlnWasmNode { self.check_lightning_supported()?; self.ensure_runtime_ready()?; let peer_pubkeys: Vec = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .list_peers() .into_iter() .map(|peer| peer.pubkey) @@ -1751,14 +1986,25 @@ impl RlnWasmNode { for pubkey in peer_pubkeys { self.disconnect_peer(pubkey).await?; } - self.ldk_runtime.stop()?; - self.runtime_core.stop(); + self.lightning_runtime()?.ldk_runtime.stop()?; + self.lightning_runtime()?.runtime_core.stop(); Ok(()) } #[wasm_bindgen(js_name = nativeRuntimeCoreStatusValue)] pub fn native_runtime_core_status_value(&self) -> Result { - let status: NativeLnRuntimeCoreStatusData = self.runtime_core.status(); + let status = self + .lightning + .borrow() + .as_ref() + .map(|runtime| runtime.runtime_core.status()) + .unwrap_or_else(|| NativeLnRuntimeCoreStatusData { + lifecycle_state: self.inactive_runtime_state().to_string(), + ready: false, + storage_initialized: false, + schema_version: 1, + queued_events: 0, + }); crate::js_obj(&status) } @@ -1773,7 +2019,7 @@ impl RlnWasmNode { pub fn drain_native_runtime_queue_value(&self) -> Result { self.check_lightning_supported()?; self.ensure_runtime_ready()?; - let drained = self.runtime_core.drain_events(); + let drained = self.lightning_runtime()?.runtime_core.drain_events(); crate::js_obj(&drained) } @@ -1788,10 +2034,10 @@ impl RlnWasmNode { pub fn process_native_runtime_queue_value(&self) -> Result { self.check_lightning_supported()?; self.ensure_runtime_ready()?; - let drained = self.runtime_core.drain_events(); + let drained = self.lightning_runtime()?.runtime_core.drain_events(); for queued in drained.iter() { apply_runtime_hook_payload( - &self.ldk_runtime, + &self.lightning_runtime()?.ldk_runtime, self.use_runtime_state_for_ln_views(), &self.peers, &self.channels, @@ -1816,7 +2062,20 @@ impl RlnWasmNode { } #[wasm_bindgen(js_name = installAutoPeerManagerHooks)] - pub fn install_auto_peer_manager_hooks(&self) { + pub fn install_auto_peer_manager_hooks(&self) -> Result<(), JsValue> { + self.prepare_lightning_runtime(false)?; + self.install_auto_peer_manager_hooks_inner(); + Ok(()) + } + + fn install_auto_peer_manager_hooks_inner(&self) { + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return; + }; + + #[cfg(test)] + crate::ln_node::test_utils::record_startup_call("peer_hooks"); + self.auto_hooks_installed.set(true); let payments = self.payments.clone(); let peers = self.peers.clone(); let channels = self.channels.clone(); @@ -1824,7 +2083,7 @@ impl RlnWasmNode { let runtime_events = self.runtime_events.clone(); let next_runtime_event_seq = self.next_runtime_event_seq.clone(); let runtime_event_store_key = self.persistence_keys.runtime_events_storage_key.clone(); - let ldk_runtime = self.ldk_runtime.clone(); + let ldk_runtime = runtime.ldk_runtime.clone(); let use_runtime_state_for_ln_views = self.use_runtime_state_for_ln_views(); let configured_network = Rc::clone(&self.configured_network); let check_lightning_supported = @@ -1929,6 +2188,7 @@ impl RlnWasmNode { ensure_manual_status_update_allowed(self.use_runtime_state_for_ln_views())?; if self.use_runtime_state_for_ln_views() { let pending_hashes = self + .lightning_runtime()? .ldk_runtime .list_payments() .into_iter() @@ -2032,7 +2292,12 @@ impl RlnWasmNode { .unwrap_or_else(|| parsed.recover_payee_pub_key()) .to_string(); let has_connected_peer = if self.use_runtime_state_for_ln_views() { - if self.ldk_runtime.get_peer(&payee_pubkey).is_some() { + if self + .lightning_runtime()? + .ldk_runtime + .get_peer(&payee_pubkey) + .is_some() + { self.has_connected_peer(&payee_pubkey) } else { self.has_any_connected_peer() @@ -2067,7 +2332,9 @@ impl RlnWasmNode { { self.register_rgb_ln_transfer_from_payment(&payment); } - self.ldk_runtime.record_payment_initiated(); + self.lightning_runtime()? + .ldk_runtime + .record_payment_initiated(); if self.use_runtime_state_for_ln_views() { let runtime_payment = self .payments @@ -2077,7 +2344,9 @@ impl RlnWasmNode { .ok_or_else(|| { JsValue::from_str(sdk_contracts::ERR_PAYMENT_NOT_FOUND_AFTER_CREATION) })?; - self.ldk_runtime.upsert_payment(runtime_payment); + self.lightning_runtime()? + .ldk_runtime + .upsert_payment(runtime_payment); } if !has_connected_peer { let _ = @@ -2087,7 +2356,8 @@ impl RlnWasmNode { } self.persist_runtime_event_log_state(); let final_status = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .get_payment(&payment_hash) .map(|payment| payment.status) .ok_or_else(|| { @@ -2201,7 +2471,9 @@ impl RlnWasmNode { { self.register_rgb_ln_transfer_from_payment(&payment); } - self.ldk_runtime.record_keysend_initiated(); + self.lightning_runtime()? + .ldk_runtime + .record_keysend_initiated(); if self.use_runtime_state_for_ln_views() { let runtime_payment = self .payments @@ -2211,7 +2483,9 @@ impl RlnWasmNode { .ok_or_else(|| { JsValue::from_str(sdk_contracts::ERR_PAYMENT_NOT_FOUND_AFTER_KEYSEND) })?; - self.ldk_runtime.upsert_payment(runtime_payment); + self.lightning_runtime()? + .ldk_runtime + .upsert_payment(runtime_payment); } if !has_connected_peer { let _ = @@ -2221,7 +2495,8 @@ impl RlnWasmNode { } self.persist_runtime_event_log_state(); let final_status = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .get_payment(&payment_hash) .map(|payment| payment.status) .ok_or_else(|| { @@ -2296,9 +2571,12 @@ impl RlnWasmNode { if let Some(id) = &asset_id { validate_asset_id_format(id)?; } - let record = - self.ldk_runtime - .keysend_live(&dest_pubkey, amt_msat, asset_id, asset_amount)?; + let record = self.lightning_runtime()?.ldk_runtime.keysend_live( + &dest_pubkey, + amt_msat, + asset_id, + asset_amount, + )?; crate::js_obj(&record) } @@ -2345,9 +2623,12 @@ impl RlnWasmNode { if amt_msat == Some(0) { return Err(JsValue::from_str(sdk_contracts::ERR_AMT_MSAT_NONPOSITIVE)); } - let record = - self.ldk_runtime - .send_bolt11_live(&invoice, amt_msat, asset_id, asset_amount)?; + let record = self.lightning_runtime()?.ldk_runtime.send_bolt11_live( + &invoice, + amt_msat, + asset_id, + asset_amount, + )?; crate::js_obj(&RlnWasmNodeSendPaymentResult { payment_id: record.payment_hash.clone(), payment_hash: Some(record.payment_hash), @@ -2374,7 +2655,11 @@ impl RlnWasmNode { pub fn live_payment_value(&self, payment_hash: String) -> Result { self.check_lightning_supported()?; self.ensure_runtime_ready()?; - match self.ldk_runtime.live_payment(payment_hash.trim()) { + match self + .lightning_runtime()? + .ldk_runtime + .live_payment(payment_hash.trim()) + { Some(record) => crate::js_obj(&record), None => Ok(JsValue::NULL), } @@ -2385,7 +2670,7 @@ impl RlnWasmNode { pub fn live_payments_value(&self) -> Result { self.check_lightning_supported()?; self.ensure_runtime_ready()?; - let data = self.ldk_runtime.live_payments(); + let data = self.lightning_runtime()?.ldk_runtime.live_payments(); crate::js_obj(&data) } @@ -2394,7 +2679,8 @@ impl RlnWasmNode { self.check_lightning_supported()?; self.ensure_runtime_ready()?; let mut data = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .list_payments() .into_iter() .map(Self::payment_data_from_runtime_state) @@ -2455,7 +2741,8 @@ impl RlnWasmNode { return Err(JsValue::from_str(sdk_contracts::ERR_PAYMENT_HASH_EMPTY)); } let data = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .get_payment(&payment_hash) .map(Self::payment_data_from_runtime_state) .ok_or_else(|| JsValue::from_str(sdk_contracts::ERR_PAYMENT_NOT_FOUND))? @@ -2610,7 +2897,12 @@ impl RlnWasmNode { let payment_hash_bytes = decode_fixed_hex::<32>(&payment_hash_hex, "invalid payment_hash")?; if self.use_runtime_state_for_ln_views() { - if self.ldk_runtime.get_payment(&payment_hash_hex).is_some() { + if self + .lightning_runtime()? + .ldk_runtime + .get_payment(&payment_hash_hex) + .is_some() + { return Err(JsValue::from_str( sdk_contracts::ERR_PAYMENT_HASH_ALREADY_USED, )); @@ -2677,7 +2969,9 @@ impl RlnWasmNode { { self.register_rgb_ln_transfer_from_payment(&payment); } - self.ldk_runtime.record_invoice_created(); + self.lightning_runtime()? + .ldk_runtime + .record_invoice_created(); if self.use_runtime_state_for_ln_views() { let runtime_payment = self .payments @@ -2687,7 +2981,9 @@ impl RlnWasmNode { .ok_or_else(|| { JsValue::from_str(sdk_contracts::ERR_PAYMENT_NOT_FOUND_AFTER_INVOICE_CREATION) })?; - self.ldk_runtime.upsert_payment(runtime_payment); + self.lightning_runtime()? + .ldk_runtime + .upsert_payment(runtime_payment); } crate::js_obj(&RlnWasmNodeCreateLnInvoiceData { @@ -2753,13 +3049,13 @@ impl RlnWasmNode { if let Some(id) = &asset_id { validate_asset_id_format(id)?; } - let invoice = self.ldk_runtime.create_bolt11_invoice_live( - amt_msat, - expiry_sec, - asset_id, - asset_amount, - )?; - self.ldk_runtime.record_invoice_created(); + let invoice = self + .lightning_runtime()? + .ldk_runtime + .create_bolt11_invoice_live(amt_msat, expiry_sec, asset_id, asset_amount)?; + self.lightning_runtime()? + .ldk_runtime + .record_invoice_created(); crate::js_obj(&RlnWasmNodeCreateLnInvoiceData { invoice }) } @@ -2812,14 +3108,19 @@ impl RlnWasmNode { } let payment_hash = payment_hash.trim().to_string(); decode_fixed_hex::<32>(&payment_hash, "invalid payment_hash")?; - let invoice = self.ldk_runtime.create_hodl_bolt11_invoice_live( - amt_msat, - expiry_sec, - asset_id, - asset_amount, - &payment_hash, - )?; - self.ldk_runtime.record_invoice_created(); + let invoice = self + .lightning_runtime()? + .ldk_runtime + .create_hodl_bolt11_invoice_live( + amt_msat, + expiry_sec, + asset_id, + asset_amount, + &payment_hash, + )?; + self.lightning_runtime()? + .ldk_runtime + .record_invoice_created(); return crate::js_obj(&RlnWasmNodeCreateLnInvoiceData { invoice }); } self.create_ln_invoice_value_internal( @@ -2862,11 +3163,14 @@ impl RlnWasmNode { } decode_fixed_hex::<32>(&payment_hash, "invalid payment_hash")?; if self.use_runtime_state_for_ln_views() { - self.ldk_runtime.cancel_hodl_invoice_live(&payment_hash)?; + self.lightning_runtime()? + .ldk_runtime + .cancel_hodl_invoice_live(&payment_hash)?; return crate::js_obj(&serde_json::json!({})); } let mut payment = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .get_payment(&payment_hash) .map(Self::payment_data_from_runtime_state) .ok_or_else(|| JsValue::from_str(sdk_contracts::ERR_LN_INVOICE_UNKNOWN))? @@ -2898,7 +3202,8 @@ impl RlnWasmNode { payment.updated_at = unix_now_secs(); self.sync_rgb_ln_transfer_from_payment(&payment); if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .upsert_payment(Self::payment_runtime_state_from_data(&payment)); } else { self.payments @@ -2943,13 +3248,15 @@ impl RlnWasmNode { } if self.use_runtime_state_for_ln_views() { let changed = self + .lightning_runtime()? .ldk_runtime .claim_hodl_invoice_live(&payment_hash, &payment_preimage)?; return crate::js_obj(&RlnWasmNodeClaimHodlInvoiceData { changed }); } let mut payment = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .get_payment(&payment_hash) .map(Self::payment_data_from_runtime_state) .ok_or_else(|| JsValue::from_str(sdk_contracts::ERR_LN_INVOICE_UNKNOWN))? @@ -2992,7 +3299,8 @@ impl RlnWasmNode { self.sync_rgb_ln_transfer_from_payment(&payment); if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .upsert_payment(Self::payment_runtime_state_from_data(&payment)); } else { self.payments @@ -3026,7 +3334,8 @@ impl RlnWasmNode { .map_err(|e| JsValue::from_str(&format!("invalid invoice: {e}")))?; let payment_hash = parsed.payment_hash().to_string(); let payment = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .get_payment(&payment_hash) .map(Self::payment_data_from_runtime_state) .ok_or_else(|| JsValue::from_str(sdk_contracts::ERR_LN_INVOICE_UNKNOWN))? @@ -3047,7 +3356,8 @@ impl RlnWasmNode { self.apply_payment_status_via_event_stream(&payment_hash, "expired", "node_api")?; } let status = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .get_payment(&payment_hash) .map(|entry| entry.status) .ok_or_else(|| JsValue::from_str(sdk_contracts::ERR_LN_INVOICE_UNKNOWN))? @@ -3302,7 +3612,9 @@ impl RlnWasmNode { ))); } let has_peer = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime.has_connected_peer(&peer_pubkey) + self.lightning_runtime()? + .ldk_runtime + .has_connected_peer(&peer_pubkey) } else { self.peers.borrow().contains_key(&peer_pubkey) }; @@ -3349,18 +3661,19 @@ impl RlnWasmNode { None }; if !is_virtual_open { - let opened = - self.ldk_runtime - .open_channel_non_virtual(LdkRuntimeOpenChannelRequestData { - peer_pubkey: peer_pubkey.clone(), - capacity_sat, - public, - asset_id: asset_id.clone(), - asset_local_amount, - contract_id: contract_id.clone(), - consignment_endpoint: consignment_endpoint.clone(), - asset_schema: asset_schema.clone(), - })?; + let opened = self + .lightning_runtime()? + .ldk_runtime + .open_channel_non_virtual(LdkRuntimeOpenChannelRequestData { + peer_pubkey: peer_pubkey.clone(), + capacity_sat, + public, + asset_id: asset_id.clone(), + asset_local_amount, + contract_id: contract_id.clone(), + consignment_endpoint: consignment_endpoint.clone(), + asset_schema: asset_schema.clone(), + })?; let temp = opened.temporary_channel_id.trim().to_string(); let chan = opened.channel_id.trim().to_string(); if !temp.is_empty() { @@ -3377,7 +3690,8 @@ impl RlnWasmNode { } let reserved_temporary_channel_id = if is_virtual_open { Some( - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .virtual_channel_add_intent(&peer_pubkey, Some(temporary_channel_id.clone())) .map_err(|e| JsValue::from_str(&e))?, ) @@ -3425,7 +3739,8 @@ impl RlnWasmNode { }; if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .upsert_channel(Self::channel_runtime_state_from_data(&data)); } // Also maintain the local channel view as a cache for WASM consumers. In wasm-native mode, @@ -3439,13 +3754,15 @@ impl RlnWasmNode { }, ); if is_virtual_open { - self.ldk_runtime.virtual_channel_session_add_from_open( - &channel_id, - reserved_temporary_channel_id - .as_deref() - .unwrap_or(&temporary_channel_id), - &data.peer_pubkey, - ); + self.lightning_runtime()? + .ldk_runtime + .virtual_channel_session_add_from_open( + &channel_id, + reserved_temporary_channel_id + .as_deref() + .unwrap_or(&temporary_channel_id), + &data.peer_pubkey, + ); self.register_trusted_virtual_scope_channel(&channel_id, &peer_pubkey); let queued_event = RuntimeTransportEvent::ChannelUsable { channel_id: channel_id.clone(), @@ -3455,13 +3772,17 @@ impl RlnWasmNode { })?; let payload_hex = hex::encode(payload_json.as_bytes()); let _ = self + .lightning_runtime()? .runtime_core .enqueue_event("channel_usable".to_string(), payload_hex); } - self.ldk_runtime.record_channel_opened(); + self.lightning_runtime()? + .ldk_runtime + .record_channel_opened(); self.persist_runtime_event_log_state(); let channel = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .list_channels() .into_iter() .find(|entry| entry.channel_id == channel_id) @@ -3556,13 +3877,17 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = driveRgbFundingWork)] pub async fn drive_rgb_funding_work(&self) -> Result<(), JsValue> { self.check_lightning_supported()?; - self.ldk_runtime.drive_rgb_funding_work_boxed().await + self.lightning_runtime()? + .ldk_runtime + .drive_rgb_funding_work_boxed() + .await } #[wasm_bindgen(js_name = processPendingRgbTransactions)] pub async fn process_pending_rgb_transactions(&self) -> Result<(), JsValue> { self.check_lightning_supported()?; - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .process_pending_rgb_transactions_boxed() .await } @@ -3575,6 +3900,7 @@ impl RlnWasmNode { self.check_lightning_supported()?; self.ensure_runtime_ready()?; let response = self + .lightning_runtime()? .ldk_runtime .apay_new_boxed(host_node_id, None, None) .await?; @@ -3600,6 +3926,7 @@ impl RlnWasmNode { self.check_lightning_supported()?; self.ensure_runtime_ready()?; let response = self + .lightning_runtime()? .ldk_runtime .apay_new_boxed(host_node_id, Some(username), Some(domain)) .await?; @@ -3637,7 +3964,10 @@ impl RlnWasmNode { if channel_id.trim().is_empty() { return Err(JsValue::from_str(sdk_contracts::ERR_CHANNEL_ID_EMPTY)); } - let virtual_session = self.ldk_runtime.virtual_channel_session_get(&channel_id); + let virtual_session = self + .lightning_runtime()? + .ldk_runtime + .virtual_channel_session_get(&channel_id); if let Some(session) = virtual_session.as_ref() { let Some(peer_pubkey) = peer_pubkey.as_ref() else { return Err(JsValue::from_str( @@ -3658,7 +3988,8 @@ impl RlnWasmNode { } } let channel = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .list_channels() .into_iter() .find(|channel| channel.channel_id == channel_id) @@ -3672,10 +4003,13 @@ impl RlnWasmNode { let Some(channel) = channel else { if let Some(session) = virtual_session.as_ref() { if session.status != LdkRuntimeVirtualChannelSessionStatusData::Abandoned { - let _ = self.ldk_runtime.virtual_channel_session_update_status( - &channel_id, - LdkRuntimeVirtualChannelSessionStatusData::Abandoned, - ); + let _ = self + .lightning_runtime()? + .ldk_runtime + .virtual_channel_session_update_status( + &channel_id, + LdkRuntimeVirtualChannelSessionStatusData::Abandoned, + ); } self.unregister_trusted_virtual_scope_channel(&channel_id); self.persist_runtime_event_log_state(); @@ -3753,11 +4087,16 @@ impl RlnWasmNode { } // `peer_pubkey` was validated and unwrapped to a trimmed `String` above. self.ensure_virtual_cleanup_client_no_local_value(&channel)?; - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .virtual_channel_abandon_local(&channel_id, &peer_pubkey)?; - self.ldk_runtime.remove_channel(&channel_id); + self.lightning_runtime()? + .ldk_runtime + .remove_channel(&channel_id); self.unregister_trusted_virtual_scope_channel(&channel_id); - self.ldk_runtime.record_channel_closed(); + self.lightning_runtime()? + .ldk_runtime + .record_channel_closed(); self.persist_runtime_event_log_state(); return Ok(()); } @@ -3779,8 +4118,11 @@ impl RlnWasmNode { // Removing optimistically on the request would report the channel as gone while it is // still open on-chain (funds locked) whenever the close stalls (e.g. an RGB colored-close // negotiation that has not produced `closing_signed` yet). - self.ldk_runtime - .close_live_channel(&channel_id, &channel.peer_pubkey, force)?; + self.lightning_runtime()?.ldk_runtime.close_live_channel( + &channel_id, + &channel.peer_pubkey, + force, + )?; let mut closing = channel.clone(); closing.status = if force { "force_closing".to_string() @@ -3790,21 +4132,27 @@ impl RlnWasmNode { closing.ready = false; closing.is_usable = false; if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .upsert_channel(Self::channel_runtime_state_from_data(&closing)); } else if let Some(entry) = self.channels.borrow_mut().get_mut(&channel_id) { entry.data = closing; } - self.ldk_runtime.record_channel_closed(); + self.lightning_runtime()? + .ldk_runtime + .record_channel_closed(); self.persist_runtime_event_log_state(); return Ok(()); } // ---- trusted virtual channel close (host-authoritative; removal is immediate) ---- - let _ = self.ldk_runtime.virtual_channel_session_update_status( - &channel_id, - LdkRuntimeVirtualChannelSessionStatusData::AbandonPending, - ); + let _ = self + .lightning_runtime()? + .ldk_runtime + .virtual_channel_session_update_status( + &channel_id, + LdkRuntimeVirtualChannelSessionStatusData::AbandonPending, + ); let applied = self .apply_and_record_transport_event( RuntimeTransportEvent::ChannelClosed { @@ -3815,11 +4163,15 @@ impl RlnWasmNode { .applied; if !applied { let live_virtual_channel_still_exists = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime.list_channels().into_iter().any(|entry| { - entry.channel_id == channel_id - && entry.virtual_open_mode.as_deref() - == Some(SDK_VIRTUAL_OPEN_MODE_TRUSTED_NO_BROADCAST) - }) + self.lightning_runtime()? + .ldk_runtime + .list_channels() + .into_iter() + .any(|entry| { + entry.channel_id == channel_id + && entry.virtual_open_mode.as_deref() + == Some(SDK_VIRTUAL_OPEN_MODE_TRUSTED_NO_BROADCAST) + }) } else { self.channels.borrow().values().any(|entry| { entry.data.channel_id == channel_id @@ -3828,26 +4180,37 @@ impl RlnWasmNode { }) }; if live_virtual_channel_still_exists { - let _ = self.ldk_runtime.virtual_channel_session_update_status( - &channel_id, - LdkRuntimeVirtualChannelSessionStatusData::Active, - ); + let _ = self + .lightning_runtime()? + .ldk_runtime + .virtual_channel_session_update_status( + &channel_id, + LdkRuntimeVirtualChannelSessionStatusData::Active, + ); return Err(JsValue::from_str(sdk_contracts::ERR_CHANNEL_NOT_FOUND)); } - let _ = self.ldk_runtime.virtual_channel_session_update_status( - &channel_id, - LdkRuntimeVirtualChannelSessionStatusData::Abandoned, - ); + let _ = self + .lightning_runtime()? + .ldk_runtime + .virtual_channel_session_update_status( + &channel_id, + LdkRuntimeVirtualChannelSessionStatusData::Abandoned, + ); self.unregister_trusted_virtual_scope_channel(&channel_id); self.persist_runtime_event_log_state(); return Ok(()); } - let _ = self.ldk_runtime.virtual_channel_session_update_status( - &channel_id, - LdkRuntimeVirtualChannelSessionStatusData::Abandoned, - ); + let _ = self + .lightning_runtime()? + .ldk_runtime + .virtual_channel_session_update_status( + &channel_id, + LdkRuntimeVirtualChannelSessionStatusData::Abandoned, + ); self.unregister_trusted_virtual_scope_channel(&channel_id); - self.ldk_runtime.record_channel_closed(); + self.lightning_runtime()? + .ldk_runtime + .record_channel_closed(); self.persist_runtime_event_log_state(); Ok(()) } @@ -3890,7 +4253,8 @@ impl RlnWasmNode { .local_node_pubkey_string() .ok_or_else(|| JsValue::from_str(sdk_contracts::ERR_NODE_IDENTITY_DERIVE_FAILED))?; let payments = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .list_payments() .into_iter() .map(Self::payment_data_from_runtime_state) @@ -4066,7 +4430,8 @@ impl RlnWasmNode { )); } let channel_id = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .find_channel_by_temporary(&temporary_channel_id) .ok_or_else(|| JsValue::from_str(sdk_contracts::ERR_TEMPORARY_CHANNEL_ID_UNKNOWN))? } else { @@ -4397,13 +4762,18 @@ impl RlnWasmNode { } fn use_runtime_state_for_ln_views(&self) -> bool { - let backend = self.ldk_runtime.status().backend; - backend == "wasm_native_ldk" + self.lightning + .borrow() + .as_ref() + .is_some_and(|runtime| runtime.ldk_runtime.status().backend == "wasm_native_ldk") } fn has_any_connected_peer(&self) -> bool { + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return false; + }; if self.use_runtime_state_for_ln_views() { - self.ldk_runtime.has_any_connected_peer() + runtime.ldk_runtime.has_any_connected_peer() } else { self.peers .borrow() @@ -4413,8 +4783,11 @@ impl RlnWasmNode { } fn has_connected_peer(&self, peer_pubkey: &str) -> bool { + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return false; + }; if self.use_runtime_state_for_ln_views() { - self.ldk_runtime.has_connected_peer(peer_pubkey) + runtime.ldk_runtime.has_connected_peer(peer_pubkey) } else { self.peers .borrow() @@ -4446,13 +4819,29 @@ impl RlnWasmNode { } fn local_node_pubkey_string(&self) -> Option { - if self.use_runtime_state_for_ln_views() { - if let Ok(pubkey) = self.ldk_runtime.live_node_pubkey() { - let trimmed = pubkey.trim().to_string(); - if !trimmed.is_empty() { - return Some(trimmed); - } - } + // The historical live backend exposes KeysManager's hardened child zero when an + // online wallet is attached. Derive that identity without constructing its LDK graph. + let identity_wallet = self + .wallet + .borrow() + .clone() + .or_else(|| self.runtime_scope.identity_wallet.borrow().clone()); + let has_online_wallet = match identity_wallet { + Some(wallet) => wallet.try_borrow().ok()?.get_online().is_some(), + None => false, + }; + if has_online_wallet { + let secp = Secp256k1::new(); + let master = + bitcoin::bip32::Xpriv::new_master(bitcoin::Network::Testnet, &self.live_node_seed) + .ok()?; + let child = master + .derive_priv( + &secp, + &[bitcoin::bip32::ChildNumber::from_hardened_idx(0).ok()?], + ) + .ok()?; + return Some(SecpPublicKey::from_secret_key(&secp, &child.private_key).to_string()); } self.node_signing_identity() .ok() @@ -4477,8 +4866,14 @@ impl RlnWasmNode { } fn trusted_virtual_success_eligible(&self, payee_pubkey: &str) -> bool { - let has_usable_trusted_virtual_channel = - self.ldk_runtime.list_channels().into_iter().any(|entry| { + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return false; + }; + let has_usable_trusted_virtual_channel = runtime + .ldk_runtime + .list_channels() + .into_iter() + .any(|entry| { entry.peer_pubkey == payee_pubkey && entry.is_usable && entry.virtual_open_mode.as_deref() @@ -4500,10 +4895,13 @@ impl RlnWasmNode { } fn routed_success_eligible(&self, payment_hash: &str, payee_pubkey: &str) -> bool { + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return false; + }; if !self.has_any_connected_peer() { return false; } - let has_usable_channel = self + let has_usable_channel = runtime .ldk_runtime .list_channels() .into_iter() @@ -4560,6 +4958,7 @@ impl RlnWasmNode { } let direct_connected = self.has_connected_peer(payee_pubkey); let has_usable_channel = self + .lightning_runtime()? .ldk_runtime .list_channels() .into_iter() @@ -4682,10 +5081,13 @@ impl RlnWasmNode { payment_hash: &str, payee_pubkey: &str, ) { + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return; + }; let Some(local_node_pubkey) = self.local_node_pubkey_string() else { return; }; - let Some(payment) = self + let Some(payment) = runtime .ldk_runtime .get_payment(payment_hash) .map(Self::payment_data_from_runtime_state) @@ -4805,12 +5207,17 @@ impl RlnWasmNode { /// True if this node has a virtual (`trusted_no_broadcast`) channel to `dest_pubkey`. fn has_virtual_channel_to(&self, dest_pubkey: &str) -> bool { - self.ldk_runtime + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return false; + }; + runtime + .ldk_runtime .list_channels() .into_iter() .filter(|c| c.peer_pubkey == dest_pubkey) .any(|c| { - self.ldk_runtime + runtime + .ldk_runtime .virtual_channel_session_get(&c.channel_id) .is_some() }) @@ -5010,10 +5417,11 @@ impl RlnWasmNode { ) -> Result { let payload_hex = encode_payment_status_event_payload(payment_hash, status); let _ = self + .lightning_runtime()? .runtime_core .enqueue_event("payment_status".to_string(), payload_hex.clone()); apply_runtime_hook_payload( - &self.ldk_runtime, + &self.lightning_runtime()?.ldk_runtime, self.use_runtime_state_for_ln_views(), &self.peers, &self.channels, @@ -5025,7 +5433,8 @@ impl RlnWasmNode { )?; self.persist_runtime_event_log_state(); let payment = if self.use_runtime_state_for_ln_views() { - self.ldk_runtime + self.lightning_runtime()? + .ldk_runtime .get_payment(payment_hash) .map(Self::payment_data_from_runtime_state) .ok_or_else(|| JsValue::from_str(sdk_contracts::ERR_PAYMENT_NOT_FOUND)) @@ -5048,13 +5457,18 @@ impl RlnWasmNode { ) -> Result { let payload_hex = encode_payment_status_event_payload(payment_hash, status); let _ = self + .lightning_runtime()? .runtime_core .enqueue_event("payment_status".to_string(), payload_hex.clone()); if self.use_runtime_state_for_ln_views() { let received_at = unix_now_secs(); let seq = next_runtime_event_seq(&self.next_runtime_event_seq); let normalized = normalize_payment_status(status)?; - let Some(mut payment) = self.ldk_runtime.get_payment(payment_hash) else { + let Some(mut payment) = self + .lightning_runtime()? + .ldk_runtime + .get_payment(payment_hash) + else { let error = "payment not found".to_string(); record_runtime_event( &self.runtime_events, @@ -5097,7 +5511,9 @@ impl RlnWasmNode { } payment.status = normalized.clone(); payment.updated_at = unix_now_secs(); - self.ldk_runtime.upsert_payment(payment.clone()); + self.lightning_runtime()? + .ldk_runtime + .upsert_payment(payment.clone()); record_runtime_event( &self.runtime_events, RlnWasmNodeRuntimeEventData { @@ -5149,6 +5565,7 @@ impl RlnWasmNode { source: &str, ) -> Result { let _ = self + .lightning_runtime()? .runtime_core .enqueue_event("transport".to_string(), payload_hex.clone()); let Some(event) = parse_transport_event_payload(&payload_hex) else { @@ -5182,6 +5599,7 @@ impl RlnWasmNode { source: &str, ) -> Result { let _ = self + .lightning_runtime()? .runtime_core .enqueue_event(event.event_kind().to_string(), payload_hex.clone()); let received_at = unix_now_secs(); @@ -5245,20 +5663,7 @@ impl RlnWasmNode { } fn node_signing_identity(&self) -> Result<(SecretKey, SecpPublicKey), JsValue> { - let sdk_seed = crate::sdk_node_identity_seed(); - let secret_hash = Sha256::hash( - format!( - "node-signing-key:{}:{}:{}", - sdk_seed.as_deref().unwrap_or("ephemeral"), - self.proxy_url, - self.node_runtime_id.as_deref().unwrap_or("") - ) - .as_bytes(), - ); - let secret_key = SecretKey::from_slice(&secret_hash.to_byte_array()) - .map_err(|e| JsValue::from_str(&format!("failed to derive node signing key: {e}")))?; - let pubkey = SecpPublicKey::from_secret_key(&Secp256k1::new(), &secret_key); - Ok((secret_key, pubkey)) + derive_node_signing_identity(&self.proxy_url, self.node_runtime_id.as_deref()) } fn sign_node_message(&self, message: &str) -> Result { @@ -5288,11 +5693,14 @@ impl RlnWasmNode { } fn apply_runtime_transport_event(&self, event: &RuntimeTransportEvent) -> bool { + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return false; + }; if self.use_runtime_state_for_ln_views() { return match event { RuntimeTransportEvent::PeerDisconnected { peer_pubkey } => { - let removed_peer = self.ldk_runtime.remove_peer(peer_pubkey); - let removed_virtual_channel_ids = self + let removed_peer = runtime.ldk_runtime.remove_peer(peer_pubkey); + let removed_virtual_channel_ids = runtime .ldk_runtime .list_channels() .into_iter() @@ -5304,17 +5712,17 @@ impl RlnWasmNode { .map(|entry| entry.channel_id) .collect::>(); let removed_channels = - self.ldk_runtime.remove_channels_by_peer(peer_pubkey) > 0; + runtime.ldk_runtime.remove_channels_by_peer(peer_pubkey) > 0; for channel_id in removed_virtual_channel_ids { self.unregister_trusted_virtual_scope_channel(&channel_id); } removed_peer || removed_channels } RuntimeTransportEvent::PeerReconnected { peer_pubkey } => { - self.ldk_runtime.has_peer(peer_pubkey) + runtime.ldk_runtime.has_peer(peer_pubkey) } RuntimeTransportEvent::ChannelClosed { channel_id } => { - let removed_runtime = self.ldk_runtime.remove_channel(channel_id); + let removed_runtime = runtime.ldk_runtime.remove_channel(channel_id); let mut local_channels = self.channels.borrow_mut(); let local_before = local_channels.len(); local_channels.retain(|_, entry| { @@ -5329,10 +5737,10 @@ impl RlnWasmNode { removed } RuntimeTransportEvent::ChannelUsable { channel_id } => { - self.ldk_runtime.set_channel_usable(channel_id, true) + runtime.ldk_runtime.set_channel_usable(channel_id, true) } RuntimeTransportEvent::ChannelUnusable { channel_id } => { - self.ldk_runtime.set_channel_usable(channel_id, false) + runtime.ldk_runtime.set_channel_usable(channel_id, false) } }; } @@ -5350,7 +5758,7 @@ impl RlnWasmNode { let removed_channels = channels.len() != before; if removed_channels { for channel_id in removed_channel_ids { - let _ = self.ldk_runtime.virtual_channel_session_update_status( + let _ = runtime.ldk_runtime.virtual_channel_session_update_status( &channel_id, LdkRuntimeVirtualChannelSessionStatusData::Abandoned, ); @@ -5365,7 +5773,7 @@ impl RlnWasmNode { RuntimeTransportEvent::ChannelClosed { channel_id } => { let removed = self.channels.borrow_mut().remove(channel_id).is_some(); if removed { - let _ = self.ldk_runtime.virtual_channel_session_update_status( + let _ = runtime.ldk_runtime.virtual_channel_session_update_status( channel_id, LdkRuntimeVirtualChannelSessionStatusData::Abandoned, ); @@ -5419,6 +5827,13 @@ impl RlnWasmNode { store_id: String, signing_key_hex: String, ) -> Result { + self.check_lightning_supported()?; + if self.runtime_scope.network_transition.replace(true) { + return Err(JsValue::from_str( + "runtime network selection is in progress", + )); + } + let _transition = NodeNetworkTransition(Rc::clone(&self.runtime_scope)); let signing_key = crate::vss_kv_store::parse_vss_config(&server_url, &store_id, &signing_key_hex)?; @@ -5455,6 +5870,7 @@ impl RlnWasmNode { let replicator = crate::vss_replicator::VssReplicator::new(store, instance_id); crate::vss_replicator::register_vss_replicator(&runtime_key, replicator); + self.runtime_scope.vss_owned.set(true); // Make sure any IndexedDB-only state is hydrated into localStorage first, so // the restore guard sees an already-populated store and doesn't overwrite it. @@ -5464,13 +5880,20 @@ impl RlnWasmNode { } .await; match restore_result { - Ok(restored) => Ok(restored as u32), + Ok(restored) => { + if self.configured_network.borrow().as_str() == "unknown" { + *self.configured_network.borrow_mut() = "regtest".to_string(); + *self.network.borrow_mut() = "regtest".to_string(); + } + Ok(restored as u32) + } Err(e) => { // Roll back completely: leaving the replicator registered (live // replication over a never-restored store) or the guards held (every // retry failing with "another tab...") after reporting failure would // wedge the caller. The persisted instance id survives, so a retry // re-acquires the same fence. + self.runtime_scope.vss_owned.set(false); crate::vss_replicator::teardown_vss_replication(&runtime_key); Err(e) } @@ -5483,6 +5906,7 @@ impl RlnWasmNode { /// take over cleanly. Local persistence is unaffected. #[wasm_bindgen(js_name = disableLdkVssReplication)] pub fn disable_ldk_vss_replication(&self) { + self.runtime_scope.vss_owned.set(false); crate::vss_replicator::teardown_vss_replication(&self.runtime_manager_key()); } @@ -5551,22 +5975,46 @@ impl RlnWasmNode { impl Drop for RlnWasmNode { fn drop(&mut self) { - let runtime_key = self.runtime_manager_key(); - let was_last = - crate::ldk_runtime::release_runtime_manager_if_last(&runtime_key, &self.ldk_runtime); - // Release the VSS single-writer guards so a same-tab restart or a takeover - // isn't wedged (fence release is best-effort/async; the Web Lock is freed - // synchronously — the browser would also free it on context destruction). - // Only when this was the LAST handle for the runtime: multiple RlnWasmNode - // handles can share a runtime_key (recreate-in-place, stale JS handles being - // GC-finalized), and tearing down on any drop would silently kill a live - // node's replication. - if was_last { - crate::vss_replicator::teardown_vss_replication(&runtime_key); + *self.reconnect_manager_running.borrow_mut() = false; + *self.auto_drive_running.borrow_mut() = false; + self.bridge.release_node_hooks(); + for (_, peer) in self.peers.borrow_mut().drain() { + peer.session.stop(); + spawn_local(async move { + let _ = peer.session.close().await; + }); + } + if Rc::strong_count(&self.runtime_scope) == 1 + && self.lightning.borrow().is_none() + && self.runtime_scope.vss_owned.replace(false) + { + crate::vss_replicator::teardown_vss_replication(&self.runtime_manager_key()); } } } +fn derive_node_signing_identity( + proxy_url: &str, + runtime_id: Option<&str>, +) -> Result<(SecretKey, SecpPublicKey), JsValue> { + let sdk_seed = crate::sdk_node_identity_seed(); + let secret_hash = Sha256::hash( + format!( + "node-signing-key:{}:{}:{}", + sdk_seed.as_deref().unwrap_or("ephemeral"), + proxy_url, + runtime_id.unwrap_or("") + ) + .as_bytes(), + ); + let secret_key = SecretKey::from_slice(&secret_hash.to_byte_array()) + .map_err(|e| JsValue::from_str(&format!("failed to derive node signing key: {e}")))?; + Ok(( + secret_key, + SecpPublicKey::from_secret_key(&Secp256k1::new(), &secret_key), + )) +} + fn unix_now_secs() -> u64 { (js_sys::Date::now() as u64) / 1000 } @@ -6094,7 +6542,7 @@ fn apply_runtime_event_payload( /// Free-function form of the chain-sync→LDK bridge so it can be driven from both the manual /// `chainSyncTick` (`&self` wrapper) and the autonomous drive loop (cloned `Rc` handles), without /// duplicating the confirmation-ordering logic. Behavior is identical to the previous `&self` -/// method; only `self.chain_sync`/`self.ldk_runtime` became explicit parameters. +/// method; the runtime components are passed explicitly. async fn apply_chain_sync_to_live_ldk( chain_sync: &WasmChainSyncDriver, ldk_runtime: &Rc, diff --git a/bindings/wasm-sdk/src/ln_runtime_native.rs b/bindings/wasm-sdk/src/ln_runtime_native.rs index 0b52f292..10d3170b 100644 --- a/bindings/wasm-sdk/src/ln_runtime_native.rs +++ b/bindings/wasm-sdk/src/ln_runtime_native.rs @@ -99,6 +99,8 @@ pub struct NativeLnRuntimeCore { impl NativeLnRuntimeCore { pub fn new(runtime_key: String) -> Self { + #[cfg(test)] + crate::ln_node::test_utils::record_startup_call("runtime_core"); let storage_key_base = format!("{WASM_LN_RUNTIME_CORE_STORAGE_PREFIX}{runtime_key}"); let snapshot = load_snapshot_with_recovery(&storage_key_base).unwrap_or_default(); Self { diff --git a/bindings/wasm-sdk/src/peer_session.rs b/bindings/wasm-sdk/src/peer_session.rs index fe9b6120..cc07e1b9 100644 --- a/bindings/wasm-sdk/src/peer_session.rs +++ b/bindings/wasm-sdk/src/peer_session.rs @@ -137,6 +137,9 @@ impl RegisteredPeerManagerHooks { thread_local! { static RLN_LDK_PEER_MANAGER_HOOKS: RefCell>> = RefCell::new(None); static RLN_LDK_PEER_MANAGER_HOOKS_V2_READY: Cell = const { Cell::new(false) }; + // Explicit clear disables owned-hook fallback; automatic owner release must not + // disconnect other nodes that still own their registrations. + static RLN_LDK_PEER_MANAGER_HOOKS_EXPLICITLY_CLEARED: Cell = const { Cell::new(false) }; } pub fn install_rln_ldk_peer_manager_hooks(hooks: RlnLdkPeerManagerHooks) { @@ -147,6 +150,7 @@ pub fn install_rln_ldk_peer_manager_hooks(hooks: RlnLdkPeerManagerHooks) { } fn install_registered_peer_manager_hooks(hooks: Rc) { + RLN_LDK_PEER_MANAGER_HOOKS_EXPLICITLY_CLEARED.with(|cleared| cleared.set(false)); RLN_LDK_PEER_MANAGER_HOOKS.with(|slot| { slot.replace(Some(hooks)); }); @@ -154,6 +158,7 @@ fn install_registered_peer_manager_hooks(hooks: Rc) } pub fn clear_rln_ldk_peer_manager_hooks() { + RLN_LDK_PEER_MANAGER_HOOKS_EXPLICITLY_CLEARED.with(|cleared| cleared.set(true)); RLN_LDK_PEER_MANAGER_HOOKS.with(|slot| { slot.replace(None); }); @@ -488,6 +493,7 @@ pub struct RlnWasmPeerSession { peer_pubkey: String, adapter: Rc, started: Cell, + active_pump: RefCell>>>, read_loop_closure: RefCell>>, } @@ -542,6 +548,7 @@ impl RlnWasmPeerSession { Rc::new(RefCell::new(VecDeque::new())); let draining = Rc::new(Cell::new(false)); let disconnected = Rc::new(Cell::new(false)); + self.active_pump.replace(Some(Rc::clone(&disconnected))); let outbound_queue: Rc>> = Rc::new(RefCell::new(VecDeque::new())); let outbound_flush_scheduled = Rc::new(Cell::new(false)); @@ -717,6 +724,9 @@ impl RlnWasmPeerSession { #[wasm_bindgen(js_name = stop)] pub fn stop(&self) { + if let Some(disconnected) = self.active_pump.borrow_mut().take() { + disconnected.set(true); + } self.socket.stop_read_loop(); self.read_loop_closure.replace(None); self.started.set(false); @@ -861,9 +871,28 @@ struct RustPeerManagerState { pub struct RlnWasmRustPeerManagerBridge { inner: Rc>, node_hooks: Rc>>>, + node_policy: Rc Result<(), JsValue>>>>>, } impl RlnWasmRustPeerManagerBridge { + pub(crate) fn set_node_policy(&self, policy: Rc Result<(), JsValue>>) { + self.node_policy.replace(Some(policy)); + } + + pub(crate) fn release_node_hooks(&self) { + if let Some(owned) = self.node_hooks.borrow_mut().take() { + RLN_LDK_PEER_MANAGER_HOOKS.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot + .as_ref() + .is_some_and(|current| Rc::ptr_eq(current, &owned)) + { + *slot = None; + } + }); + } + } + pub(crate) fn install_node_hooks( &self, hooks: RlnLdkPeerManagerHooks, @@ -877,14 +906,34 @@ impl RlnWasmRustPeerManagerBridge { install_registered_peer_manager_hooks(registration); } + pub(crate) fn connection_hooks_ready(&self) -> Result<(bool, bool), JsValue> { + let available = self.hooks_for_connection()?.is_some(); + let v2_ready = available + && (get_rln_ldk_peer_manager_hooks().is_none() || has_peer_manager_hooks_v2()); + Ok((available, v2_ready)) + } + fn hooks_for_connection(&self) -> Result>, JsValue> { + if let Some(policy) = self.node_policy.borrow().as_ref() { + policy()?; + if self.node_hooks.borrow().is_none() { + return Err(JsValue::from_str("Lightning runtime is not initialized")); + } + } let node_hooks = self.node_hooks.borrow().clone(); if let Some(node) = node_hooks.as_ref() { node.check_lightning_supported()?; } - // Clearing the global hooks must not resurrect a node's automatic callbacks. let Some(global) = get_rln_ldk_peer_manager_hooks() else { - return Ok(None); + // Preserve explicit clear, but dropping another node's global registration + // must leave this node's own runtime connected to its bridge. + return Ok( + if RLN_LDK_PEER_MANAGER_HOOKS_EXPLICITLY_CLEARED.with(|cleared| cleared.get()) { + None + } else { + node_hooks + }, + ); }; let Some(node) = node_hooks else { return Ok(Some(global)); @@ -919,6 +968,7 @@ impl RlnWasmRustPeerManagerBridge { ..Default::default() })), node_hooks: Rc::new(RefCell::new(None)), + node_policy: Rc::new(RefCell::new(None)), }) } @@ -1134,6 +1184,7 @@ async fn peer_session_connect_with_adapter( peer_pubkey, adapter, started: Cell::new(false), + active_pump: RefCell::new(None), read_loop_closure: RefCell::new(None), }) } diff --git a/bindings/wasm-sdk/src/runtime_store.rs b/bindings/wasm-sdk/src/runtime_store.rs index 4a789886..90108eb6 100644 --- a/bindings/wasm-sdk/src/runtime_store.rs +++ b/bindings/wasm-sdk/src/runtime_store.rs @@ -40,6 +40,8 @@ pub(crate) fn browser_persistent_state_store() -> BrowserPersistentStateStore { } pub(crate) const RUNTIME_STATE_HYDRATE_PREFIXES: &[&str] = &[ + "rln:ldk-kv:", + "rln:wasm:ldk-sweeps:", crate::wasm_node_persistence::WASM_LDK_RUNTIME_STORAGE_PREFIX, "rln:wasm:swap-runtime:", "rln:wasm:media:", @@ -82,7 +84,7 @@ async fn hydrate_local_storage_from_indexed_db_prefixes(prefixes: &[&str]) -> Re continue; }; if prefixes.iter().any(|prefix| key.starts_with(prefix)) { - let _ = local_storage_set_item(&key, &value); + local_storage_set_item(&key, &value)?; } } @@ -92,6 +94,57 @@ async fn hydrate_local_storage_from_indexed_db_prefixes(prefixes: &[&str]) -> Re Ok(()) } +/// Conservative, read-only preflight. Synchronous node constructors can inspect durable +/// state only after the caller has completed the existing asynchronous hydration step. +pub(crate) fn check_mainnet_runtime_state( + keys: &crate::wasm_node_persistence::RuntimeScopeKeys, +) -> Result<(), JsValue> { + #[cfg(target_arch = "wasm32")] + { + if !RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow()) { + return Err(JsValue::from_str( + "Mainnet node initialization requires await sdk.preloadPersistentRuntimeState() before construction or wallet attachment", + )); + } + let storage = web_sys::window() + .ok_or_else(|| JsValue::from_str("browser window unavailable"))? + .local_storage()? + .ok_or_else(|| { + JsValue::from_str("localStorage unavailable for mainnet recovery review") + })?; + let runtime = &keys.ldk_manager_registry_key; + let protected = [ + keys.ldk_runtime_committed_storage_key.clone(), + keys.native_ln_runtime_core_storage_base.clone(), + keys.chain_sync_storage_key.clone(), + keys.runtime_events_storage_key.clone(), + keys.rgb_ln_transfers_storage_key.clone(), + keys.peer_sessions_storage_key.clone(), + format!("rln:wasm:ldk-broadcast-queue:{runtime}"), + format!("rln:wasm:ldk-monitors:{runtime}"), + format!("rln:wasm:ldk-sweeps:{runtime}"), + format!("rln:ldk-kv:{runtime}"), + ]; + for index in 0..storage.length()? { + if let Some(key) = storage.key(index)? { + if protected.iter().any(|prefix| { + key == *prefix + || key + .strip_prefix(prefix.as_str()) + .is_some_and(|suffix| suffix.starts_with(':')) + }) { + return Err(JsValue::from_str( + "MainnetLightningState: Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: protected browser runtime state is present", + )); + } + } + } + } + #[cfg(not(target_arch = "wasm32"))] + let _ = keys; + Ok(()) +} + #[cfg(not(target_arch = "wasm32"))] async fn hydrate_local_storage_from_indexed_db_prefixes(_prefixes: &[&str]) -> Result<(), JsValue> { Ok(()) @@ -283,3 +336,8 @@ async fn indexed_db_delete_item(key: &str) -> Result<(), JsValue> { let _ = JsFuture::from(promise).await?; Ok(()) } + +#[cfg(all(test, target_arch = "wasm32"))] +pub(crate) fn reset_preload_readiness_for_tests() { + RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow_mut() = false); +} diff --git a/bindings/wasm-sdk/src/sdk_facade.rs b/bindings/wasm-sdk/src/sdk_facade.rs index d4ffa762..41b42232 100644 --- a/bindings/wasm-sdk/src/sdk_facade.rs +++ b/bindings/wasm-sdk/src/sdk_facade.rs @@ -1480,8 +1480,8 @@ impl RlnWasmSdk { } #[wasm_bindgen(js_name = installAutoPeerManagerHooks)] - pub fn install_auto_peer_manager_hooks(&self, node: &RlnWasmNode) { - node.install_auto_peer_manager_hooks(); + pub fn install_auto_peer_manager_hooks(&self, node: &RlnWasmNode) -> Result<(), JsValue> { + node.install_auto_peer_manager_hooks() } #[wasm_bindgen(js_name = clearAutoPeerManagerHooks)] diff --git a/bindings/wasm-sdk/src/tests/ln_node_test_utils.rs b/bindings/wasm-sdk/src/tests/ln_node_test_utils.rs index db590ba1..6af9d666 100644 --- a/bindings/wasm-sdk/src/tests/ln_node_test_utils.rs +++ b/bindings/wasm-sdk/src/tests/ln_node_test_utils.rs @@ -29,6 +29,15 @@ pub(crate) fn reset_runtime_event_log_storage_for_tests() { } impl RlnWasmNode { + fn test_lightning_runtime(&self) -> Rc { + self.ensure_runtime_ready().expect("test Lightning runtime"); + self.lightning_runtime().unwrap() + } + + pub(super) fn test_ldk(&self) -> Rc { + Rc::clone(&self.test_lightning_runtime().ldk_runtime) + } + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) fn test_upsert_runtime_peer( &self, @@ -36,15 +45,31 @@ impl RlnWasmNode { peer_addr: String, started: bool, ) { - self.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { - pubkey, - peer_addr, - started, - }); + self.test_lightning_runtime() + .ldk_runtime + .upsert_peer(LdkRuntimePeerStateData { + pubkey, + peer_addr, + started, + }); } #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) fn test_set_runtime_peer_started(&self, pubkey: &str, started: bool) -> bool { - self.ldk_runtime.set_peer_started(pubkey, started) + self.test_lightning_runtime() + .ldk_runtime + .set_peer_started(pubkey, started) } } + +thread_local! { + static STARTUP_CALLS: RefCell> = RefCell::new(HashMap::new()); +} + +pub(crate) fn record_startup_call(component: &'static str) { + STARTUP_CALLS.with(|calls| *calls.borrow_mut().entry(component).or_default() += 1); +} + +pub(crate) fn startup_calls() -> HashMap<&'static str, usize> { + STARTUP_CALLS.with(|calls| calls.borrow().clone()) +} diff --git a/bindings/wasm-sdk/src/tests/ln_node_tests.rs b/bindings/wasm-sdk/src/tests/ln_node_tests.rs index b954d7bc..5e156ac1 100644 --- a/bindings/wasm-sdk/src/tests/ln_node_tests.rs +++ b/bindings/wasm-sdk/src/tests/ln_node_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::peer_session::{has_peer_manager_hooks, has_peer_manager_hooks_v2}; use futures::executor::block_on; use serde::Deserialize; use wasm_bindgen_test::wasm_bindgen_test; @@ -141,12 +142,17 @@ fn runtime_scope_key_canonicalizes_proxy_aliases_contract() { } #[test] -fn node_constructor_installs_auto_peer_manager_hooks_contract() { +fn configured_non_mainnet_constructor_installs_auto_peer_manager_hooks_contract() { clear_rln_ldk_peer_manager_hooks(); assert!(!has_peer_manager_hooks()); assert!(!has_peer_manager_hooks_v2()); - let _node = RlnWasmNode::new("ws://127.0.0.1:3001".to_string()).expect("node should build"); + let _node = RlnWasmNode::new_with_node_runtime_id( + "ws://127.0.0.1:3001".to_string(), + "auto-hooks".to_string(), + "regtest".to_string(), + ) + .expect("node should build"); assert!(has_peer_manager_hooks()); assert!(has_peer_manager_hooks_v2()); @@ -286,7 +292,7 @@ fn bridge_apply_payment_status_via_event_stream_updates_runtime_and_log() { "wasm_native_ldk".to_string(), ) .expect("node should build"); - node.ldk_runtime.upsert_payment(LdkRuntimePaymentStateData { + node.test_ldk().upsert_payment(LdkRuntimePaymentStateData { amt_msat: Some(3_000_000), asset_amount: None, asset_id: None, @@ -312,7 +318,7 @@ fn bridge_apply_payment_status_via_event_stream_updates_runtime_and_log() { assert_eq!(updated.status, "failed"); let runtime_payment = node - .ldk_runtime + .test_ldk() .get_payment("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") .expect("runtime payment should exist"); assert_eq!(runtime_payment.status, "failed"); @@ -419,7 +425,7 @@ fn hook_payload_transport_event_updates_bridge_runtime_state() { "wasm_native_ldk".to_string(), ) .expect("node should build"); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-bridge-hook".to_string(), channel_id: "chan-bridge-hook".to_string(), peer_pubkey: "peer-bridge-hook".to_string(), @@ -436,7 +442,7 @@ fn hook_payload_transport_event_updates_bridge_runtime_state() { }); apply_runtime_hook_payload( - &node.ldk_runtime, + &node.test_ldk(), true, &node.peers, &node.channels, @@ -449,7 +455,7 @@ fn hook_payload_transport_event_updates_bridge_runtime_state() { .expect("hook payload should apply"); let channel = node - .ldk_runtime + .test_ldk() .list_channels() .into_iter() .find(|entry| entry.channel_id == "chan-bridge-hook") @@ -470,14 +476,14 @@ fn hook_payload_peer_reconnected_updates_bridge_peer_started_state() { "wasm_native_ldk".to_string(), ) .expect("node should build"); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: "peer-bridge-reconnect".to_string(), peer_addr: "127.0.0.1:9735".to_string(), started: false, }); apply_runtime_hook_payload( - &node.ldk_runtime, + &node.test_ldk(), true, &node.peers, &node.channels, @@ -490,7 +496,7 @@ fn hook_payload_peer_reconnected_updates_bridge_peer_started_state() { .expect("hook payload should apply"); let peer = node - .ldk_runtime + .test_ldk() .get_peer("peer-bridge-reconnect") .expect("peer should exist"); assert!(peer.started); @@ -905,7 +911,7 @@ fn hook_payload_mixed_stream_preserves_event_order_and_terminal_payment_state_co "wasm_native_ldk".to_string(), ) .expect("node should build"); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-mixed".to_string(), channel_id: "chan-mixed".to_string(), peer_pubkey: "peer-mixed".to_string(), @@ -920,7 +926,7 @@ fn hook_payload_mixed_stream_preserves_event_order_and_terminal_payment_state_co outbound_msat: 0, next_outbound_htlc_limit_msat: 0, }); - node.ldk_runtime.upsert_payment(LdkRuntimePaymentStateData { + node.test_ldk().upsert_payment(LdkRuntimePaymentStateData { amt_msat: Some(SDK_HTLC_MIN_MSAT), asset_amount: None, asset_id: None, @@ -943,7 +949,7 @@ fn hook_payload_mixed_stream_preserves_event_order_and_terminal_payment_state_co for payload_hex in payloads { apply_runtime_hook_payload( - &node.ldk_runtime, + &node.test_ldk(), true, &node.peers, &node.channels, @@ -957,13 +963,13 @@ fn hook_payload_mixed_stream_preserves_event_order_and_terminal_payment_state_co } let payment = node - .ldk_runtime + .test_ldk() .get_payment("pay-mixed") .expect("payment should exist"); assert_eq!(payment.status, "succeeded"); let channel_exists = node - .ldk_runtime + .test_ldk() .list_channels() .iter() .any(|entry| entry.channel_id == "chan-mixed"); @@ -1161,7 +1167,7 @@ fn bridge_backend_list_peers_reads_runtime_state_contract() { "wasm_native_ldk".to_string(), ) .expect("node should build"); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: "0334cc4bca04ce3d1537310f55e91ec4cec7e5a88fa0fba20a24cce1fe6de2a2b0".to_string(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -1186,7 +1192,7 @@ fn bridge_backend_channel_views_use_runtime_state_contract() { "wasm_native_ldk".to_string(), ) .expect("node should build"); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-1".to_string(), channel_id: "chan-1".to_string(), peer_pubkey: "0334cc4bca04ce3d1537310f55e91ec4cec7e5a88fa0fba20a24cce1fe6de2a2b0" @@ -1236,7 +1242,7 @@ fn bridge_backend_payment_views_use_runtime_state_contract() { "wasm_native_ldk".to_string(), ) .expect("node should build"); - node.ldk_runtime.upsert_payment(LdkRuntimePaymentStateData { + node.test_ldk().upsert_payment(LdkRuntimePaymentStateData { amt_msat: Some(5_000), asset_amount: None, asset_id: None, @@ -1273,7 +1279,7 @@ fn bridge_backend_ingest_event_syncs_runtime_payment_state_contract() { "wasm_native_ldk".to_string(), ) .expect("node should build"); - node.ldk_runtime.upsert_payment(LdkRuntimePaymentStateData { + node.test_ldk().upsert_payment(LdkRuntimePaymentStateData { amt_msat: Some(7_000), asset_amount: None, asset_id: None, @@ -1294,7 +1300,7 @@ fn bridge_backend_ingest_event_syncs_runtime_payment_state_contract() { .expect("ingest should succeed"); let runtime_payment = node - .ldk_runtime + .test_ldk() .get_payment("pay-sync") .expect("runtime payment"); assert_eq!(runtime_payment.status, "succeeded"); @@ -1307,7 +1313,7 @@ fn bridge_backend_fail_pending_syncs_runtime_payment_state_contract() { "wasm_native_ldk".to_string(), ) .expect("node should build"); - node.ldk_runtime.upsert_payment(LdkRuntimePaymentStateData { + node.test_ldk().upsert_payment(LdkRuntimePaymentStateData { amt_msat: Some(8_000), asset_amount: None, asset_id: None, @@ -1325,7 +1331,7 @@ fn bridge_backend_fail_pending_syncs_runtime_payment_state_contract() { let _ = node.fail_pending_payments_api().expect("fail pending"); let runtime_payment = node - .ldk_runtime + .test_ldk() .get_payment("pay-fail") .expect("runtime payment"); assert_eq!(runtime_payment.status, "failed"); @@ -1339,12 +1345,12 @@ fn bridge_backend_disconnect_peer_without_local_session_contract() { ) .expect("node should build"); let pubkey = "0334cc4bca04ce3d1537310f55e91ec4cec7e5a88fa0fba20a24cce1fe6de2a2b0"; - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: pubkey.to_string(), peer_addr: "127.0.0.1:9735".to_string(), started: false, }); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-disconnect".to_string(), channel_id: "chan-disconnect".to_string(), peer_pubkey: pubkey.to_string(), @@ -1362,9 +1368,9 @@ fn bridge_backend_disconnect_peer_without_local_session_contract() { block_on(node.disconnect_peer(pubkey.to_string())).expect("disconnect should succeed"); - assert!(!node.ldk_runtime.has_peer(pubkey)); + assert!(!node.test_ldk().has_peer(pubkey)); assert!(node - .ldk_runtime + .test_ldk() .list_channels() .iter() .all(|ch| ch.peer_pubkey != pubkey)); @@ -1387,12 +1393,12 @@ fn bridge_backend_close_all_peers_clears_runtime_peers_without_sessions_contract "chan-close-2", ), ] { - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.to_string(), peer_addr: "127.0.0.1:9735".to_string(), started: false, }); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: format!("tmp-{channel_id}"), channel_id: channel_id.to_string(), peer_pubkey: peer_pubkey.to_string(), @@ -1411,8 +1417,8 @@ fn bridge_backend_close_all_peers_clears_runtime_peers_without_sessions_contract block_on(node.close_all_peers()).expect("close all peers should succeed"); - assert!(node.ldk_runtime.list_peers().is_empty()); - assert!(node.ldk_runtime.list_channels().is_empty()); + assert!(node.test_ldk().list_peers().is_empty()); + assert!(node.test_ldk().list_channels().is_empty()); } #[test] @@ -1422,13 +1428,13 @@ fn bridge_backend_runtime_state_restores_across_node_instances_contract() { let node_a = RlnWasmNode::new(proxy.clone()).expect("node should build"); node_a.ensure_runtime_ready().expect("runtime should start"); - node_a.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node_a.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: "0334cc4bca04ce3d1537310f55e91ec4cec7e5a88fa0fba20a24cce1fe6de2a2b0".to_string(), peer_addr: "127.0.0.1:9735".to_string(), started: true, }); node_a - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-restore".to_string(), channel_id: "chan-restore".to_string(), @@ -1446,7 +1452,7 @@ fn bridge_backend_runtime_state_restores_across_node_instances_contract() { next_outbound_htlc_limit_msat: 0, }); node_a - .ldk_runtime + .test_ldk() .upsert_payment(LdkRuntimePaymentStateData { amt_msat: Some(SDK_HTLC_MIN_MSAT), asset_amount: None, @@ -1499,7 +1505,7 @@ fn bridge_backend_restore_requires_peer_reconnect_before_open_channel_contract() let node_a = RlnWasmNode::new(proxy.clone()).expect("node should build"); node_a.ensure_runtime_ready().expect("runtime should start"); - node_a.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node_a.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -1528,7 +1534,7 @@ fn bridge_backend_restore_requires_peer_reconnect_before_open_channel_contract() .expect_err("open must fail before reconnect"); assert_eq!(err.as_string().unwrap_or_default(), "peer is not connected"); - assert!(node_b.ldk_runtime.set_peer_started(&peer_pubkey, true)); + assert!(node_b.test_ldk().set_peer_started(&peer_pubkey, true)); let opened_js = node_b .open_channel_value(peer_pubkey, SDK_OPENCHANNEL_MIN_SAT, false, None, None) .expect("open should succeed after reconnect"); @@ -1545,7 +1551,7 @@ fn bridge_backend_restore_disconnected_peer_forces_send_payment_failure_until_re let node_a = RlnWasmNode::new(proxy.clone()).expect("node should build"); node_a.ensure_runtime_ready().expect("runtime should start"); - node_a.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node_a.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -1556,7 +1562,7 @@ fn bridge_backend_restore_disconnected_peer_forces_send_payment_failure_until_re .ensure_runtime_ready() .expect("runtime should restore"); assert_eq!( - node_b.ldk_runtime.get_peer(&peer_pubkey).map(|p| p.started), + node_b.test_ldk().get_peer(&peer_pubkey).map(|p| p.started), Some(false) ); @@ -1576,7 +1582,7 @@ fn bridge_backend_restore_disconnected_peer_forces_send_payment_failure_until_re let first_doc: serde_json::Value = crate::js_from(first_send).expect("parse send"); assert_eq!(first_doc["status"], "failed"); - assert!(node_b.ldk_runtime.set_peer_started(&peer_pubkey, true)); + assert!(node_b.test_ldk().set_peer_started(&peer_pubkey, true)); let second_send = node_b .send_payment_value(invoice, Some(SDK_INVOICE_MIN_MSAT), None, None) .expect("send should succeed after reconnect"); @@ -1593,7 +1599,7 @@ fn bridge_backend_restore_disconnected_peer_forces_keysend_failure_until_reconne let node_a = RlnWasmNode::new(proxy.clone()).expect("node should build"); node_a.ensure_runtime_ready().expect("runtime should start"); - node_a.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node_a.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -1604,7 +1610,7 @@ fn bridge_backend_restore_disconnected_peer_forces_keysend_failure_until_reconne .ensure_runtime_ready() .expect("runtime should restore"); assert_eq!( - node_b.ldk_runtime.get_peer(&peer_pubkey).map(|p| p.started), + node_b.test_ldk().get_peer(&peer_pubkey).map(|p| p.started), Some(false) ); @@ -1614,7 +1620,7 @@ fn bridge_backend_restore_disconnected_peer_forces_keysend_failure_until_reconne let first_doc: serde_json::Value = crate::js_from(first).expect("parse keysend"); assert_eq!(first_doc["status"], "failed"); - assert!(node_b.ldk_runtime.set_peer_started(&peer_pubkey, true)); + assert!(node_b.test_ldk().set_peer_started(&peer_pubkey, true)); let second = node_b .keysend_value(peer_pubkey, SDK_HTLC_MIN_MSAT, None, None) .expect("keysend should stay pending after reconnect"); @@ -1631,7 +1637,7 @@ fn bridge_backend_trusted_virtual_keysend_finalizes_via_runtime_virtual_payment_ let node = RlnWasmNode::new(proxy).expect("node should build"); node.ensure_runtime_ready().expect("runtime should start"); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -1778,7 +1784,7 @@ fn wasm_channel_payment_state_does_not_cross_runtime_ids_contract() { node_a.ensure_runtime_ready().expect("node A runtime"); node_b.ensure_runtime_ready().expect("node B runtime"); - node_a.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node_a.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -1828,13 +1834,13 @@ fn bridge_backend_channel_api_open_get_list_close_contract() { node.ensure_runtime_ready().expect("runtime should start"); let peer_pubkey = "0334cc4bca04ce3d1537310f55e91ec4cec7e5a88fa0fba20a24cce1fe6de2a2b0".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, }); assert_eq!( - node.ldk_runtime.get_peer(&peer_pubkey).map(|p| p.started), + node.test_ldk().get_peer(&peer_pubkey).map(|p| p.started), Some(true) ); @@ -1873,7 +1879,7 @@ fn bridge_backend_channel_api_open_get_list_close_contract() { let after_close = after_close.as_array().expect("channels array"); assert!(after_close.is_empty()); assert!(node - .ldk_runtime + .test_ldk() .list_channels() .into_iter() .all(|ch| ch.channel_id != channel_id)); @@ -2594,7 +2600,7 @@ fn open_channel_non_virtual_rejects_without_mutating_state_contract() { .expect("node should build"); let peer_pubkey = "029999999999999999999999999999999999999999999999999999999999999999".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -2640,7 +2646,7 @@ fn open_channel_non_virtual_rgb_rejected_with_explicit_contract_message() { .expect("node should build"); let peer_pubkey = "02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -2673,7 +2679,7 @@ fn open_channel_virtual_mode_is_persisted_in_runtime_contract() { .expect("node should build"); let peer_pubkey = "02acacacacacacacacacacacacacacacacacacacacacacacacacacacacacacac".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -2709,7 +2715,7 @@ fn open_channel_virtual_becomes_usable_only_after_runtime_event_contract() { .expect("node should build"); let peer_pubkey = "02afafafafafafafafafafafafafafafafafafafafafafafafafafafafafafaf".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -2761,7 +2767,7 @@ fn open_channel_virtual_rejected_when_feature_disabled_contract() { node.set_enable_virtual_channels_v0(false); let peer_pubkey = "02a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -2830,7 +2836,7 @@ fn close_channel_virtual_requires_peer_pubkey_contract() { .expect("node should build"); let peer_pubkey = "02adadadadadadadadadadadadadadadadadadadadadadadadadadadadadadad".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -2872,7 +2878,7 @@ fn close_channel_rejects_force_for_virtual_channel_contract() { .expect("node should build"); let peer_pubkey = "02adadadadadadadadadadadadadadadadadadadadadadadadadadadadadadad".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -2914,7 +2920,7 @@ fn close_channel_virtual_rejected_when_feature_disabled_contract() { .expect("node should build"); let peer_pubkey = "02ababababababababababababababababababababababababababababababab".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -2957,7 +2963,7 @@ fn close_channel_rejects_virtual_cleanup_when_counterparty_btc_value_remains_con .expect("node should build"); let peer_pubkey = "02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -3010,7 +3016,7 @@ fn close_channel_allows_virtual_cleanup_after_btc_roundtrip_contract() { .expect("node should build"); let peer_pubkey = "02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -3142,12 +3148,12 @@ fn close_channel_allows_virtual_cleanup_after_authoritative_peer_keysend_roundtr .expect("node b pubkey") .to_string(); - node_a.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node_a.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: node_b_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, }); - node_b.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node_b.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: node_a_pubkey.clone(), peer_addr: "127.0.0.1:9736".to_string(), started: true, @@ -3219,7 +3225,7 @@ fn close_channel_rejects_virtual_cleanup_after_non_authoritative_inbound_credit_ .expect("node should build"); let peer_pubkey = "02cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -3283,7 +3289,7 @@ fn close_channel_rejects_virtual_cleanup_when_claimable_invoice_exists_contract( .expect("node should build"); let peer_pubkey = "02dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd".to_string(); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, @@ -3623,12 +3629,12 @@ fn bridge_send_payment_requires_connected_known_payee_peer_contract() { .expect("payee pubkey") .to_string(); - sender.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + sender.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: "0334cc4bca04ce3d1537310f55e91ec4cec7e5a88fa0fba20a24cce1fe6de2a2b0".to_string(), peer_addr: "127.0.0.1:9735".to_string(), started: true, }); - sender.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + sender.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: payee_pubkey.clone(), peer_addr: "127.0.0.1:9736".to_string(), started: false, @@ -3640,7 +3646,7 @@ fn bridge_send_payment_requires_connected_known_payee_peer_contract() { let first_doc: serde_json::Value = crate::js_from(first).expect("parse first send"); assert_eq!(first_doc["status"], "failed"); - assert!(sender.ldk_runtime.set_peer_started(&payee_pubkey, true)); + assert!(sender.test_ldk().set_peer_started(&payee_pubkey, true)); let second = sender .send_payment_value(invoice, Some(SDK_INVOICE_MIN_MSAT), None, None) .expect("send payment should be pending after payee reconnect"); @@ -3679,14 +3685,14 @@ fn bridge_send_payment_on_usable_channel_finalizes_via_runtime_channel_payment_e .expect("payee pubkey") .to_string(); - sender.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + sender.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: payee_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, }); assert!(sender.test_set_runtime_peer_started(&payee_pubkey, true)); sender - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-chan-runtime-success".to_string(), channel_id: "chan-runtime-success".to_string(), @@ -3774,13 +3780,13 @@ fn bridge_send_payment_propagates_receiver_terminal_status_and_rgb_transfer_cont .expect("payee pubkey") .to_string(); - sender.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + sender.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: payee_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, }); sender - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-rx-propagation".to_string(), channel_id: "chan-rx-propagation".to_string(), @@ -3923,12 +3929,12 @@ fn close_channel_regular_coop_and_force_contracts() { "wasm_native_ldk".to_string(), ) .expect("node"); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, }); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-close-regular-1".to_string(), channel_id: "chan-close-regular-1".to_string(), peer_pubkey: peer_pubkey.clone(), @@ -3956,7 +3962,7 @@ fn close_channel_regular_coop_and_force_contracts() { let channels: serde_json::Value = crate::js_from(channels_js).expect("parse channels"); assert!(channels.as_array().expect("channels array").is_empty()); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-close-regular-2".to_string(), channel_id: "chan-close-regular-2".to_string(), peer_pubkey: peer_pubkey.clone(), @@ -3993,12 +3999,12 @@ fn close_channel_regular_coop_persists_across_node_recreation_contract() { let node = RlnWasmNode::new_with_runtime_backend(runtime_proxy.clone(), "wasm_native_ldk".to_string()) .expect("node"); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, }); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-close-restart-1".to_string(), channel_id: "chan-close-restart-1".to_string(), peer_pubkey: peer_pubkey.clone(), @@ -4059,13 +4065,13 @@ fn close_channel_regular_force_records_channel_closed_sequence_contract() { "wasm_native_ldk".to_string(), ) .expect("node"); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey.clone(), peer_addr: "127.0.0.1:9735".to_string(), started: true, }); let channel_id = "chan-close-force-1".to_string(); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-close-force-1".to_string(), channel_id: channel_id.clone(), peer_pubkey: peer_pubkey.clone(), @@ -4123,12 +4129,12 @@ fn close_channel_counterparty_event_persists_across_node_recreation_contract() { let node = RlnWasmNode::new_with_runtime_backend(runtime_proxy.clone(), "wasm_native_ldk".to_string()) .expect("node"); - node.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + node.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: peer_pubkey, peer_addr: "127.0.0.1:9735".to_string(), started: true, }); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-close-counterparty-1".to_string(), channel_id: "chan-close-counterparty-1".to_string(), peer_pubkey: "03cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" @@ -4294,12 +4300,12 @@ fn multi_hop_route_without_direct_payee_finalizes_via_runtime_routed_engine_cont .expect("payment hash") .to_string(); let mut receiver_payment = receiver - .ldk_runtime + .test_ldk() .get_payment(&payment_hash) .expect("receiver pending payment must exist"); receiver_payment.status = "pending".to_string(); receiver_payment.updated_at = unix_now_secs(); - receiver.ldk_runtime.upsert_payment(receiver_payment); + receiver.test_ldk().upsert_payment(receiver_payment); let relay_invoice_json = relay .create_ln_invoice_json(Some(SDK_INVOICE_MIN_MSAT), 3600, None, None) @@ -4321,7 +4327,7 @@ fn multi_hop_route_without_direct_payee_finalizes_via_runtime_routed_engine_cont sender.test_upsert_runtime_peer(relay_pubkey.clone(), "127.0.0.1:9735".to_string(), true); assert!(sender.test_set_runtime_peer_started(&relay_pubkey, true)); sender - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-mh-relay".to_string(), channel_id: "chan-mh-relay".to_string(), @@ -4431,7 +4437,7 @@ fn multi_hop_route_prefers_direct_usable_channel_over_routed_engine_contract() { sender.test_upsert_runtime_peer(payee_pubkey.clone(), "127.0.0.1:9735".to_string(), true); assert!(sender.test_set_runtime_peer_started(&payee_pubkey, true)); sender - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-mh-direct-pref-payee".to_string(), channel_id: "chan-mh-direct-pref-payee".to_string(), @@ -4455,7 +4461,7 @@ fn multi_hop_route_prefers_direct_usable_channel_over_routed_engine_contract() { ); assert!(sender.test_set_runtime_peer_started(&recovered_payee_pubkey, true)); sender - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-mh-direct-pref-payee-recovered".to_string(), channel_id: "chan-mh-direct-pref-payee-recovered".to_string(), @@ -4475,7 +4481,7 @@ fn multi_hop_route_prefers_direct_usable_channel_over_routed_engine_contract() { sender.test_upsert_runtime_peer(relay_pubkey.clone(), "127.0.0.1:9736".to_string(), true); assert!(sender.test_set_runtime_peer_started(&relay_pubkey, true)); sender - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-mh-direct-pref-relay".to_string(), channel_id: "chan-mh-direct-pref-relay".to_string(), @@ -4563,12 +4569,12 @@ fn multi_hop_route_requires_pending_receiver_invoice_contract() { .to_string(); let mut receiver_payment = receiver - .ldk_runtime + .test_ldk() .get_payment(&payment_hash) .expect("receiver pending payment must exist"); receiver_payment.status = "failed".to_string(); receiver_payment.updated_at = unix_now_secs(); - receiver.ldk_runtime.upsert_payment(receiver_payment); + receiver.test_ldk().upsert_payment(receiver_payment); let relay_invoice_json = relay .create_ln_invoice_json(Some(SDK_INVOICE_MIN_MSAT), 3600, None, None) @@ -4585,13 +4591,13 @@ fn multi_hop_route_requires_pending_receiver_invoice_contract() { .to_string(); let _ = sender.list_peers_value().expect("warm sender runtime"); - sender.ldk_runtime.upsert_peer(LdkRuntimePeerStateData { + sender.test_ldk().upsert_peer(LdkRuntimePeerStateData { pubkey: relay_pubkey.clone(), peer_addr: "127.0.0.1:9736".to_string(), started: true, }); sender - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-mh-route-gate-relay".to_string(), channel_id: "chan-mh-route-gate-relay".to_string(), @@ -4608,7 +4614,7 @@ fn multi_hop_route_requires_pending_receiver_invoice_contract() { next_outbound_htlc_limit_msat: 0, }); assert!( - sender.ldk_runtime.get_peer(&payee_pubkey).is_none(), + sender.test_ldk().get_peer(&payee_pubkey).is_none(), "sender should not have direct payee peer for routed scenario" ); @@ -4705,7 +4711,7 @@ fn vanilla_payment_on_rgb_channel_success_path_contract() { sender.test_upsert_runtime_peer(payee_pubkey.clone(), "127.0.0.1:9735".to_string(), true); assert!(sender.test_set_runtime_peer_started(&payee_pubkey, true)); sender - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-vanilla-rgb-1".to_string(), channel_id: "chan-vanilla-rgb-1".to_string(), @@ -4729,7 +4735,7 @@ fn vanilla_payment_on_rgb_channel_success_path_contract() { ); assert!(sender.test_set_runtime_peer_started(&recovered_payee_pubkey, true)); sender - .ldk_runtime + .test_ldk() .upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-vanilla-rgb-1-recovered".to_string(), channel_id: "chan-vanilla-rgb-1-recovered".to_string(), @@ -4786,11 +4792,12 @@ fn sdk_facade_forwards_network_info() { let node = sdk .new_node("ws://127.0.0.1:3001".to_string()) .expect("new node"); - let network_js = sdk.network_info_value(&node).expect("network info"); - let network: serde_json::Value = - serde_wasm_bindgen::from_value(network_js).expect("parse network"); - assert_eq!(network["network"], "regtest"); - assert_eq!(network["height"], 0); + assert!(sdk + .network_info_value(&node) + .unwrap_err() + .as_string() + .unwrap() + .starts_with("NetworkInfoUnavailable:")); } #[test] @@ -4801,7 +4808,7 @@ fn sdk_facade_forwards_node_info() { .expect("new node"); let node_js = sdk.node_info_value(&node).expect("node info"); let info: serde_json::Value = serde_wasm_bindgen::from_value(node_js).expect("parse node"); - assert_eq!(info["ldk_over_websocket"], true); + assert_eq!(info["ldk_over_websocket"], false); assert!(info["runtime"].as_str().is_some()); } @@ -5016,11 +5023,12 @@ fn sdk_node_handle_forwards_network_info() { let node = sdk .create_node_handle("ws://127.0.0.1:3001".to_string()) .expect("node handle"); - let network_js = node.network_info_value().expect("network info"); - let network: serde_json::Value = - serde_wasm_bindgen::from_value(network_js).expect("parse network"); - assert_eq!(network["network"], "regtest"); - assert_eq!(network["height"], 0); + assert!(node + .network_info_value() + .unwrap_err() + .as_string() + .unwrap() + .starts_with("NetworkInfoUnavailable:")); } #[test] @@ -5031,7 +5039,7 @@ fn sdk_node_handle_forwards_node_info() { .expect("node handle"); let node_js = node.node_info_value().expect("node info"); let info: serde_json::Value = serde_wasm_bindgen::from_value(node_js).expect("parse node"); - assert_eq!(info["ldk_over_websocket"], true); + assert_eq!(info["ldk_over_websocket"], false); assert!(info["runtime"].as_str().is_some()); } @@ -6963,7 +6971,7 @@ fn list_channels_merges_runtime_metadata_from_local_cache_contract() { data: channel, }, ); - node.ldk_runtime.upsert_channel(LdkRuntimeChannelStateData { + node.test_ldk().upsert_channel(LdkRuntimeChannelStateData { temporary_channel_id: "tmp-local-rich".to_string(), channel_id: "chan-local-rich".to_string(), peer_pubkey: String::new(), diff --git a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs index 270af85f..add216da 100644 --- a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs +++ b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::peer_session::has_peer_manager_hooks; use crate::{RlnWasmSdk, RlnWasmSdkNodeHandle, RlnWasmWallet}; use wasm_bindgen_test::wasm_bindgen_test; @@ -21,12 +22,33 @@ fn configured_node(network: &str) -> RlnWasmNode { .expect("configured node") } +async fn mainnet_wallet() -> RlnWasmWallet { + let mut wallet_data: serde_json::Value = + serde_json::from_str(&crate::test_utils::test_wallet_data_json()).unwrap(); + let keys = rgb_lib_wasm::restore_keys( + rgb_lib_wasm::BitcoinNetwork::Mainnet, + wallet_data["mnemonic"].as_str().unwrap().to_string(), + ) + .expect("mainnet keys"); + wallet_data["bitcoin_network"] = serde_json::json!("Mainnet"); + wallet_data["supported_schemas"] = serde_json::json!(["Nia"]); + wallet_data["account_xpub_vanilla"] = serde_json::json!(keys.account_xpub_vanilla); + wallet_data["account_xpub_colored"] = serde_json::json!(keys.account_xpub_colored); + RlnWasmWallet::create(&wallet_data.to_string()) + .await + .expect("mainnet wallet") +} + #[wasm_bindgen_test(async)] async fn mainnet_lightning_operations_reject_before_runtime_or_state_changes() { crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); let node = configured_node("mainnet"); - let runtime_before = serde_json::to_value(node.ldk_runtime.status()).unwrap(); - let core_before = serde_json::to_value(node.runtime_core.status()).unwrap(); + assert!(node.lightning.borrow().is_none()); + let runtime_before = node.ldk_runtime_status_json().unwrap(); + let core_before = node.native_runtime_core_status_json().unwrap(); assert_mainnet_rejection(node.connect_peer(String::new(), String::new()).await); assert_mainnet_rejection(node.disconnect_peer(String::new()).await); @@ -34,6 +56,14 @@ async fn mainnet_lightning_operations_reject_before_runtime_or_state_changes() { assert_mainnet_rejection(node.reconnect_manager_start_value()); assert_mainnet_rejection(node.auto_drive_start_value(0)); assert_mainnet_rejection(node.chain_sync_tick_value().await); + assert_mainnet_rejection(node.chain_sync_start_value(String::new(), None)); + assert_mainnet_rejection(node.chain_sync_enqueue_rebroadcast_tx(String::new(), String::new())); + assert_mainnet_rejection(node.persist_ldk_runtime_state()); + assert_mainnet_rejection(node.install_auto_peer_manager_hooks()); + assert_mainnet_rejection( + node.configure_ldk_vss_replication(String::new(), String::new(), String::new()) + .await, + ); assert_mainnet_rejection(node.list_peers_value()); assert_mainnet_rejection(node.list_channels_value()); assert_mainnet_rejection(node.open_channel_value(String::new(), 0, false, None, None)); @@ -73,14 +103,9 @@ async fn mainnet_lightning_operations_reject_before_runtime_or_state_changes() { .await, ); - assert_eq!( - serde_json::to_value(node.ldk_runtime.status()).unwrap(), - runtime_before - ); - assert_eq!( - serde_json::to_value(node.runtime_core.status()).unwrap(), - core_before - ); + assert_eq!(node.ldk_runtime_status_json().unwrap(), runtime_before); + assert_eq!(node.native_runtime_core_status_json().unwrap(), core_before); + assert!(node.lightning.borrow().is_none()); assert!(node.channels.borrow().is_empty()); assert!(node.payments.borrow().is_empty()); assert!(node.runtime_events.borrow().is_empty()); @@ -88,9 +113,12 @@ async fn mainnet_lightning_operations_reject_before_runtime_or_state_changes() { assert!(!*node.auto_drive_running.borrow()); } -#[wasm_bindgen_test] -fn mainnet_lightning_error_propagates_through_facade_and_json_handles() { +#[wasm_bindgen_test(async)] +async fn mainnet_lightning_error_propagates_through_facade_and_json_handles() { crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); let node = configured_node("MAINNET"); let sdk = RlnWasmSdk::new(); assert_mainnet_rejection(sdk.list_channels_json(&node)); @@ -100,10 +128,13 @@ fn mainnet_lightning_error_propagates_through_facade_and_json_handles() { assert_mainnet_rejection(handle.get_channel_id(String::new())); } -#[wasm_bindgen_test] -fn non_mainnet_lightning_queries_and_validation_are_unchanged() { +#[wasm_bindgen_test(async)] +async fn non_mainnet_lightning_queries_and_validation_are_unchanged() { for network in ["testnet", "testnet4", "signet", "regtest"] { crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); let node = configured_node(network); node.check_lightning_supported().expect("supported network"); assert_eq!(node.list_channels_json().expect("list channels"), "[]"); @@ -120,20 +151,10 @@ fn non_mainnet_lightning_queries_and_validation_are_unchanged() { #[wasm_bindgen_test(async)] async fn mainnet_wallet_remains_available_and_adopted_network_restricts_lightning() { crate::test_utils::reset_wasm_runtime_state_for_tests(); - let mut wallet_data: serde_json::Value = - serde_json::from_str(&crate::test_utils::test_wallet_data_json()).unwrap(); - let keys = rgb_lib_wasm::restore_keys( - rgb_lib_wasm::BitcoinNetwork::Mainnet, - wallet_data["mnemonic"].as_str().unwrap().to_string(), - ) - .expect("mainnet keys"); - wallet_data["bitcoin_network"] = serde_json::json!("Mainnet"); - wallet_data["supported_schemas"] = serde_json::json!(["Nia"]); - wallet_data["account_xpub_vanilla"] = serde_json::json!(keys.account_xpub_vanilla); - wallet_data["account_xpub_colored"] = serde_json::json!(keys.account_xpub_colored); - let wallet = RlnWasmWallet::create(&wallet_data.to_string()) + crate::runtime_store::preload_runtime_state_from_persistent_store() .await - .expect("mainnet wallet"); + .unwrap(); + let wallet = mainnet_wallet().await; assert!(wallet .get_address() .expect("on-chain address") @@ -152,8 +173,8 @@ async fn mainnet_wallet_remains_available_and_adopted_network_restricts_lightnin let _busy_wallet = wallet.inner.borrow_mut(); assert_mainnet_rejection(node.list_channels_json()); } - // Hooks were installed before attachWallet adopted mainnet; their guard must observe it. - let bridge = RlnWasmRustPeerManagerBridge::new(None).unwrap(); + // The node-owned cold bridge follows adopted policy without installing global hooks. + let bridge = node.bridge.clone(); assert_mainnet_rejection( bridge .connect_session(String::new(), String::new(), String::new()) @@ -178,35 +199,48 @@ async fn mainnet_wallet_remains_available_and_adopted_network_restricts_lightnin .starts_with("bc1")); } -#[wasm_bindgen_test] -fn mainnet_guard_uses_configured_network_despite_restored_chain_sync_status() { +#[wasm_bindgen_test(async)] +async fn mainnet_refuses_saved_running_chain_state_without_resuming_or_changing_it() { crate::test_utils::reset_wasm_runtime_state_for_tests(); - let proxy_url = "ws://mainnet-stale-network.invalid".to_string(); - let runtime_id = "mainnet-stale-network".to_string(); - let previous = RlnWasmNode::new_with_node_runtime_id( - proxy_url.clone(), - runtime_id.clone(), - "regtest".to_string(), - ) - .unwrap(); - previous.chain_sync.set_network("regtest").unwrap(); - drop(previous); - let node = RlnWasmNode::new_with_node_runtime_id(proxy_url, runtime_id, "mainnet".to_string()) + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await .unwrap(); - assert_eq!(node.chain_sync.status().network, "regtest"); - node.chain_sync_start_value("http://127.0.0.1:1".to_string(), None) + let proxy = "ws://mainnet-legacy-chain.invalid"; + let runtime_id = "mainnet-legacy-chain"; + let keys = RuntimeScopeKeys::from_runtime_scope_key(runtime_scope_key(proxy, Some(runtime_id))); + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let saved = r#"{"network":"regtest","running":true,"indexer_url":"http://127.0.0.1:1"}"#; + storage + .set_item(&keys.chain_sync_storage_key, saved) .unwrap(); - node.chain_sync_stop_value().unwrap(); - assert_eq!(node.network.borrow().as_str(), "regtest"); - assert_mainnet_rejection(node.decode_ln_invoice_value(String::new())); - assert_mainnet_rejection(node.list_channels_value()); + let result = RlnWasmNode::new_with_node_runtime_id( + proxy.to_string(), + runtime_id.to_string(), + "mainnet".to_string(), + ); + let error = result.err().expect("protected saved state must refuse"); + assert!(error + .as_string() + .unwrap() + .starts_with("MainnetLightningState:")); + assert_eq!( + storage + .get_item(&keys.chain_sync_storage_key) + .unwrap() + .as_deref(), + Some(saved) + ); + storage.remove_item(&keys.chain_sync_storage_key).unwrap(); } #[wasm_bindgen_test(async)] async fn mainnet_peer_bridge_rejects_before_opening_a_socket() { crate::test_utils::reset_wasm_runtime_state_for_tests(); - let _node = configured_node("mainnet"); - let bridge = RlnWasmRustPeerManagerBridge::new(None).unwrap(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let node = configured_node("mainnet"); + let bridge = node.bridge.clone(); // An invalid WebSocket URL would fail during socket creation if the guard ran too late. assert_mainnet_rejection( bridge @@ -223,7 +257,7 @@ async fn mainnet_peer_bridge_rejects_before_opening_a_socket() { ) .await, ); - // Clearing the hooks removes the standalone bridge's configured-node registration. + // Mainnet construction never installs process-global hooks. clear_rln_ldk_peer_manager_hooks(); assert!(!has_peer_manager_hooks()); } @@ -231,6 +265,9 @@ async fn mainnet_peer_bridge_rejects_before_opening_a_socket() { #[wasm_bindgen_test(async)] async fn lightning_guard_does_not_borrow_busy_onchain_wallet() { crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); let wallet = RlnWasmWallet::create(&crate::test_utils::test_wallet_data_json()) .await .expect("regtest wallet"); @@ -248,9 +285,12 @@ async fn lightning_guard_does_not_borrow_busy_onchain_wallet() { ); } -#[wasm_bindgen_test] -fn mainnet_reconnect_resume_rejects_through_node_and_wrappers() { +#[wasm_bindgen_test(async)] +async fn mainnet_reconnect_resume_rejects_through_node_and_wrappers() { crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); let node = configured_node("mainnet"); let sdk = RlnWasmSdk::new(); assert_mainnet_rejection(node.reconnect_manager_on_resume()); @@ -261,6 +301,9 @@ fn mainnet_reconnect_resume_rejects_through_node_and_wrappers() { for network in ["testnet", "testnet4", "signet", "regtest"] { crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); let node = configured_node(network); node.reconnect_manager_on_resume() .expect("inactive reconnect remains a no-op on supported networks"); @@ -278,6 +321,9 @@ fn mainnet_reconnect_resume_rejects_through_node_and_wrappers() { async fn node_peer_bridges_keep_their_network_in_both_creation_orders() { for mainnet_first in [false, true] { crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); let (mainnet, regtest) = if mainnet_first { let mainnet = configured_node("mainnet"); (mainnet, configured_node("regtest")) @@ -328,3 +374,306 @@ async fn node_peer_bridges_keep_their_network_in_both_creation_orders() { } } } + +#[wasm_bindgen_test(async)] +async fn mainnet_constructor_and_shared_calls_never_enter_lightning_factories() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let before = test_utils::startup_calls(); + let hooks_before = has_peer_manager_hooks(); + let scopes_before = KNOWN_RUNTIME_SCOPE_KEYS.with(|scopes| scopes.borrow().clone()); + let node = configured_node("mainnet"); + let info: serde_json::Value = serde_json::from_str(&node.node_info_json().unwrap()).unwrap(); + assert_eq!(info["ldk_over_websocket"], false); + assert_eq!(info["num_channels"], 0); + assert!(info["runtime"].as_str().unwrap().ends_with(":disabled")); + let status: serde_json::Value = + serde_json::from_str(&node.ldk_runtime_status_json().unwrap()).unwrap(); + assert_eq!(status["ready"], false); + assert_eq!(status["storage_initialized"], false); + node.ldk_runtime_components_json().unwrap(); + node.native_runtime_core_status_json().unwrap(); + node.chain_sync_status_json().unwrap(); + node.chain_sync_stop_json().unwrap(); + node.ldk_vss_backup_info_json().unwrap(); + assert!(node + .network_info_value() + .unwrap_err() + .as_string() + .unwrap() + .starts_with("NetworkInfoUnavailable:")); + let expected_identity = + derive_node_signing_identity("ws://mainnet-guard.invalid", Some("mainnet-guard-mainnet")) + .unwrap(); + let pubkey: serde_json::Value = + serde_json::from_str(&node.node_pubkey_json().unwrap()).unwrap(); + assert_eq!(pubkey["pubkey"], expected_identity.1.to_string()); + let signed: serde_json::Value = + serde_json::from_str(&node.sign_message_json("on-chain mainnet".into()).unwrap()).unwrap(); + let bytes = hex::decode(signed["signed_message"].as_str().unwrap()).unwrap(); + let signature = secp256k1::ecdsa::RecoverableSignature::from_compact( + &bytes[..64], + secp256k1::ecdsa::RecoveryId::from_i32(bytes[64] as i32).unwrap(), + ) + .unwrap(); + let message = + SecpMessage::from_digest_slice(&Sha256::hash(b"on-chain mainnet").to_byte_array()).unwrap(); + assert_eq!( + Secp256k1::new() + .recover_ecdsa(&message, &signature) + .unwrap(), + expected_identity.1 + ); + assert!(node.lightning.borrow().is_none()); + drop(node); + assert_eq!(test_utils::startup_calls(), before); + assert_eq!(has_peer_manager_hooks(), hooks_before); + assert_eq!( + KNOWN_RUNTIME_SCOPE_KEYS.with(|scopes| scopes.borrow().clone()), + scopes_before + ); +} + +#[wasm_bindgen_test(async)] +async fn mainnet_preload_is_required_before_constructor_or_adoption_mutates_scope() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + let wallet = mainnet_wallet().await; + crate::runtime_store::reset_preload_readiness_for_tests(); + let before = test_utils::startup_calls(); + let bare = RlnWasmNode::new("ws://mainnet-preload-adoption.invalid".into()).unwrap(); + assert!(bare + .attach_wallet(&wallet) + .unwrap_err() + .as_string() + .unwrap() + .contains("preloadPersistentRuntimeState")); + assert_eq!(bare.configured_network.borrow().as_str(), "unknown"); + assert!(bare.wallet.borrow().is_none()); + assert!(bare.lightning.borrow().is_none()); + let proxy = "ws://mainnet-preload.invalid".to_string(); + let result = + RlnWasmNode::new_with_node_runtime_id(proxy.clone(), "preload".into(), "mainnet".into()); + assert!(result + .err() + .unwrap() + .as_string() + .unwrap() + .contains("preloadPersistentRuntimeState")); + assert_eq!(test_utils::startup_calls(), before); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let node = + RlnWasmNode::new_with_node_runtime_id(proxy, "preload".into(), "mainnet".into()).unwrap(); + assert!(node.lightning.borrow().is_none()); +} + +#[wasm_bindgen_test(async)] +async fn mainnet_unknown_scope_adoption_and_failed_vss_setup_stay_cold() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let before = test_utils::startup_calls(); + let proxy = "ws://mainnet-unknown.invalid"; + let node = + RlnWasmNode::new_with_runtime_id_opt(proxy.into(), Some("shared".into()), None).unwrap(); + let cold_error = node + .bridge + .connect_session(String::new(), String::new(), String::new()) + .await + .err() + .unwrap(); + assert_eq!( + cold_error.as_string().as_deref(), + Some("Lightning runtime is not initialized") + ); + assert_eq!(test_utils::startup_calls(), before); + node.node_info_value().unwrap(); + node.node_pubkey_value().unwrap(); + assert!(node + .configure_ldk_vss_replication(String::new(), String::new(), String::new()) + .await + .is_err()); + assert_eq!(node.configured_network.borrow().as_str(), "unknown"); + let explicit = + RlnWasmNode::new_with_node_runtime_id(proxy.into(), "shared".into(), "mainnet".into()) + .unwrap(); + assert_eq!(node.configured_network.borrow().as_str(), "mainnet"); + assert_mainnet_rejection(node.list_channels_value()); + assert!(Rc::ptr_eq(&node.runtime_scope, &explicit.runtime_scope)); + assert_eq!(test_utils::startup_calls(), before); +} + +#[wasm_bindgen_test(async)] +async fn mainnet_legacy_protected_namespaces_refuse_without_modification() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let proxy = "ws://mainnet-legacy-namespaces.invalid"; + let id = "recovery"; + let keys = RuntimeScopeKeys::from_runtime_scope_key(runtime_scope_key(proxy, Some(id))); + let runtime = &keys.ldk_manager_registry_key; + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let before = test_utils::startup_calls(); + let _existing = + RlnWasmNode::new_with_node_runtime_id(proxy.into(), id.into(), "mainnet".into()).unwrap(); + for key in [ + keys.ldk_runtime_committed_storage_key.clone(), + format!("{}:pending", keys.ldk_runtime_committed_storage_key), + format!("rln:wasm:ldk-monitors:{runtime}:monitor:funding"), + format!("rln:wasm:ldk-broadcast-queue:{runtime}"), + format!("rln:wasm:ldk-sweeps:{runtime}"), + format!("rln:ldk-kv:{runtime}:monitors:monitor_updates:key"), + keys.peer_sessions_storage_key.clone(), + ] { + storage + .set_item(&key, "unknown-or-corrupt-legacy-state") + .unwrap(); + let result = + RlnWasmNode::new_with_node_runtime_id(proxy.into(), id.into(), "mainnet".into()); + assert!(result + .err() + .unwrap() + .as_string() + .unwrap() + .starts_with("MainnetLightningState:")); + assert_eq!( + storage.get_item(&key).unwrap().as_deref(), + Some("unknown-or-corrupt-legacy-state") + ); + let inherited = RlnWasmNode::new_with_runtime_id_opt(proxy.into(), Some(id.into()), None); + assert!(inherited + .err() + .unwrap() + .as_string() + .unwrap() + .starts_with("MainnetLightningState:")); + storage.remove_item(&key).unwrap(); + } + assert_eq!(test_utils::startup_calls(), before); +} + +#[wasm_bindgen_test] +fn supported_scope_reuse_never_reseeds_or_replaces_runtime_and_conflicts_are_atomic() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + let proxy = "ws://runtime-reuse.invalid"; + let node = + RlnWasmNode::new_with_node_runtime_id(proxy.into(), "shared".into(), "regtest".into()) + .unwrap(); + node.ensure_runtime_ready().unwrap(); + let runtime = node.lightning_runtime().unwrap(); + let before = test_utils::startup_calls(); + let reused = + RlnWasmNode::new_with_node_runtime_id(proxy.into(), "shared".into(), "regtest".into()) + .unwrap(); + assert!(Rc::ptr_eq(&runtime, &reused.lightning_runtime().unwrap())); + // A handle may install its own bridge hooks, but must not acquire a new manager/core/driver. + let after = test_utils::startup_calls(); + for component in [ + "manager", + "seed_assignment", + "runtime_core", + "chain_driver", + "live_graph", + "chain_task", + ] { + assert_eq!(before.get(component), after.get(component)); + } + assert!( + RlnWasmNode::new_with_node_runtime_id(proxy.into(), "shared".into(), "signet".into()) + .is_err() + ); + assert_eq!(node.configured_network.borrow().as_str(), "regtest"); + drop(reused); + assert!(runtime.ldk_runtime.status().ready); + drop(runtime); + drop(node); +} + +#[wasm_bindgen_test(async)] +async fn failed_bare_runtime_preparation_does_not_commit_regtest_or_register_hooks() { + use wasm_bindgen::JsCast; + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let proxy = "ws://runtime-preparation-failure.invalid"; + let node = + RlnWasmNode::new_with_runtime_id_opt(proxy.into(), Some("rollback".into()), None).unwrap(); + let hooks_before = has_peer_manager_hooks(); + let prototype = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Storage")).unwrap(); + let prototype = js_sys::Reflect::get(&prototype, &JsValue::from_str("prototype")).unwrap(); + let original = js_sys::Reflect::get(&prototype, &JsValue::from_str("getItem")).unwrap(); + let replacement = js_sys::Function::new_with_args("original, blocked", "return function(key) { if (key === blocked) throw new Error('injected read failure'); return original.call(this, key); };") + .call2(&JsValue::NULL, &original, &JsValue::from_str(&node.persistence_keys.chain_sync_storage_key)).unwrap(); + js_sys::Reflect::set(&prototype, &JsValue::from_str("getItem"), &replacement).unwrap(); + let result = node.ensure_runtime_ready(); + js_sys::Reflect::set(&prototype, &JsValue::from_str("getItem"), &original).unwrap(); + assert!(result.is_err()); + assert_eq!(node.configured_network.borrow().as_str(), "unknown"); + assert!(node.lightning.borrow().is_none()); + assert_eq!(has_peer_manager_hooks(), hooks_before); + // No provisional node-owned lease survives the failure. Mainnet adoption remains possible. + let mainnet = + RlnWasmNode::new_with_node_runtime_id(proxy.into(), "rollback".into(), "mainnet".into()) + .unwrap(); + assert_eq!(node.configured_network.borrow().as_str(), "mainnet"); + assert!(mainnet.lightning.borrow().is_none()); + // Ensure the preserved method remains callable after restoration. + let _: js_sys::Function = original.unchecked_into(); +} + +#[wasm_bindgen_test(async)] +async fn node_drop_releases_only_its_runtime_and_preserves_a_surviving_bridge() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let first = RlnWasmNode::new_with_node_runtime_id( + "ws://drop-owner.invalid".into(), + "first".into(), + "regtest".into(), + ) + .unwrap(); + first.ensure_runtime_ready().unwrap(); + let second = RlnWasmNode::new_with_node_runtime_id( + "ws://drop-owner.invalid".into(), + "second".into(), + "regtest".into(), + ) + .unwrap(); + second.ensure_runtime_ready().unwrap(); + let second_manager = Rc::clone(&second.lightning_runtime().unwrap().ldk_runtime); + assert!(second_manager.status().ready); + drop(second); + assert!(!second_manager.status().ready); + assert!( + first + .lightning_runtime() + .unwrap() + .ldk_runtime + .status() + .ready + ); + assert_eq!(first.bridge.connection_hooks_ready().unwrap(), (true, true)); + let mainnet = configured_node("mainnet"); + drop(mainnet); + assert_eq!(first.bridge.connection_hooks_ready().unwrap(), (true, true)); + // A stale callback may still retain the old manager. It must not reserve the + // released scope or cause the replacement to reseed that manager. + let replacement = RlnWasmNode::new_with_node_runtime_id( + "ws://drop-owner.invalid".into(), + "second".into(), + "regtest".into(), + ) + .unwrap(); + assert!(!Rc::ptr_eq( + &second_manager, + &replacement.lightning_runtime().unwrap().ldk_runtime + )); + assert!(!second_manager.status().ready); +} diff --git a/bindings/wasm-sdk/src/tests/peer_session_tests.rs b/bindings/wasm-sdk/src/tests/peer_session_tests.rs index 7e40d074..3560a51a 100644 --- a/bindings/wasm-sdk/src/tests/peer_session_tests.rs +++ b/bindings/wasm-sdk/src/tests/peer_session_tests.rs @@ -144,3 +144,48 @@ fn drain_inbound_queue_gap_replay_seq_disconnects_when_cursor_present() { fn commit_last_applied_seq_is_noop_for_empty_session_id() { commit_last_applied_seq("", 9); } + +#[wasm_bindgen_test::wasm_bindgen_test] +fn automatic_owner_release_preserves_other_node_hooks_but_explicit_clear_disables_them() { + use super::*; + fn hooks(label: &'static str) -> RlnLdkPeerManagerHooks { + RlnLdkPeerManagerHooks { + new_outbound_connection: Rc::new(move |_| Ok(label.to_string())), + read_event: Rc::new(|_, _| Ok(())), + process_events: Rc::new(|| Ok(())), + take_outbound_frames: Rc::new(|_| Ok(Vec::new())), + socket_disconnected: Rc::new(|_| Ok(())), + report_error: Rc::new(|_| Ok(())), + } + } + let first = RlnWasmRustPeerManagerBridge::new(None).unwrap(); + let second = RlnWasmRustPeerManagerBridge::new(None).unwrap(); + first.install_node_hooks(hooks("first"), Rc::new(|| Ok(()))); + second.install_node_hooks(hooks("second"), Rc::new(|| Ok(()))); + second.release_node_hooks(); + assert_eq!(first.connection_hooks_ready().unwrap(), (true, true)); + let selected = first + .hooks_for_connection() + .unwrap() + .expect("surviving owner hooks"); + assert_eq!( + (selected.hooks.new_outbound_connection)("").unwrap(), + "first" + ); + assert!(RlnWasmRustPeerManagerBridge::new(None) + .unwrap() + .hooks_for_connection() + .unwrap() + .is_none()); + clear_rln_ldk_peer_manager_hooks(); + assert!(first.hooks_for_connection().unwrap().is_none()); + assert_eq!(first.connection_hooks_ready().unwrap(), (false, false)); + install_rln_ldk_peer_manager_hooks(hooks("custom")); + first.release_node_hooks(); + let selected = get_rln_ldk_peer_manager_hooks().expect("unrelated custom hooks preserved"); + assert_eq!( + (selected.hooks.new_outbound_connection)("").unwrap(), + "custom" + ); + clear_rln_ldk_peer_manager_hooks(); +} diff --git a/bindings/wasm-sdk/src/tests/runtime_store_tests.rs b/bindings/wasm-sdk/src/tests/runtime_store_tests.rs index 659e2edd..04735e80 100644 --- a/bindings/wasm-sdk/src/tests/runtime_store_tests.rs +++ b/bindings/wasm-sdk/src/tests/runtime_store_tests.rs @@ -23,3 +23,37 @@ fn hydrate_prefixes_cover_runtime_state_domains() { ); } } + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen_test::wasm_bindgen_test(async)] +async fn mainnet_preflight_hydrates_indexeddb_only_kv_and_retains_legacy_state() { + use super::*; + let scope = "ws://mainnet-idb-recovery.invalid#runtime:identity"; + let keys = crate::wasm_node_persistence::RuntimeScopeKeys::from_runtime_scope_key(scope.into()); + let key = format!( + "rln:ldk-kv:{}:monitors:monitor_updates:pending", + keys.ldk_manager_registry_key + ); + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + indexed_db_set_item(&key, "preserve-remote-format-bytes") + .await + .unwrap(); + storage.remove_item(&key).unwrap(); + reset_preload_readiness_for_tests(); + preload_runtime_state_from_persistent_store().await.unwrap(); + assert_eq!( + storage.get_item(&key).unwrap().as_deref(), + Some("preserve-remote-format-bytes") + ); + assert!(check_mainnet_runtime_state(&keys) + .unwrap_err() + .as_string() + .unwrap() + .starts_with("MainnetLightningState:")); + assert_eq!( + storage.get_item(&key).unwrap().as_deref(), + Some("preserve-remote-format-bytes") + ); + indexed_db_delete_item(&key).await.unwrap(); + storage.remove_item(&key).unwrap(); +} From 0a0d4cfe3da76a2b3a66e0f8c3d864b37a3944a5 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Thu, 1 Oct 2026 13:23:38 +0530 Subject: [PATCH 03/14] Format browser proxy endpoint helper --- bindings/wasm-sdk/src/lib.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bindings/wasm-sdk/src/lib.rs b/bindings/wasm-sdk/src/lib.rs index da198eb6..3560d765 100644 --- a/bindings/wasm-sdk/src/lib.rs +++ b/bindings/wasm-sdk/src/lib.rs @@ -541,10 +541,15 @@ fn wallet_rgb_proxy_transport_get(idb_key: &str) -> Option Option { - let config = wallet_rgb_proxy_transport_get(idb_key).or_else(sdk_default_rgb_proxy_transport)?; + let config = + wallet_rgb_proxy_transport_get(idb_key).or_else(sdk_default_rgb_proxy_transport)?; match (&config.auth_token, &config.node_id) { (Some(token), Some(node_id)) => { - let separator = if config.endpoint.contains('?') { '&' } else { '?' }; + let separator = if config.endpoint.contains('?') { + '&' + } else { + '?' + }; Some(format!( "{}{}auth_token={}&node_id={}", config.endpoint, From 19c5b6053200098b8f8454ea3dc4f14b265e4252 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Thu, 1 Oct 2026 13:35:09 +0530 Subject: [PATCH 04/14] Verify browser identity without starting Lightning --- bindings/wasm-sdk/README.md | 23 +++- .../src/tests/mainnet_lightning_tests.rs | 119 ++++++++++++++++++ 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/bindings/wasm-sdk/README.md b/bindings/wasm-sdk/README.md index 5ef75e8f..f04eb0ab 100644 --- a/bindings/wasm-sdk/README.md +++ b/bindings/wasm-sdk/README.md @@ -39,10 +39,16 @@ MainnetLightningState: Existing Lightning state requires recovery review before This conservative check also refuses historical snapshots from a previously on-chain-only node. It checks the current browser's hydrated IndexedDB/localStorage; -it cannot inspect remote-only `-ldk` VSS recovery state because the -synchronous constructor has no VSS credentials. Review any previous device/remote -Lightning state before using that identity on Mainnet. Independent wallet objects -remain directly usable; this node check is not a process-wide wallet restriction. +it cannot inspect remote-only `-ldk` VSS recovery state. Neither the +synchronous constructor nor SDK `init`/`unlock` accepts that store's credentials. +Before reusing an identity that previously used remote Lightning storage, an +operator must review the exact VSS server, LDK store and signing identity configured +on the previous device. Wallet `configureVssBackup` configures a separate backup +stream and does not establish that the LDK store is empty. The pinned browser VSS +client cannot list all keys; an absent or empty manifest is insufficient because a +successful object write can precede a failed manifest update. The local check does +not certify remote recovery state. Independent wallet objects remain directly +usable; this node check is not a process-wide wallet restriction. Mainnet rejects peer/connect/reconnect, channel/funding, Lightning invoice/payment, async-payment and event-processing methods, including `chainSyncTick*`, @@ -59,7 +65,14 @@ LightningUnsupportedOnMainnet: RLN on mainnet currently supports only on-chain m ``` On-chain wallet APIs, RGB invoices and message signing retain their requirements and -identity derivation. Shared node/status calls do not start Lightning. An absent +identity derivation. `nodePubkey*` derives the historical live KeysManager public +key when an online wallet is attached, without constructing LDK. Without an online +wallet it retains the existing raw signing identity. Previously, a failed LDK graph +initialization could also make `nodePubkey*` fall back to the raw identity despite +an online wallet; that error-dependent fallback is no longer attempted. Message +signing continues to use its existing raw signing key in either case. + +Shared node/status calls do not start Lightning. An absent runtime reports `disabled` on Mainnet (`cold` before activation otherwise), no active components and zero active peers/channels. `chainSyncStop*` is an inactive no-op. Synchronous `networkInfo*` returns `NetworkInfoUnavailable` when no chain driver exists; it does diff --git a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs index add216da..0beb2db4 100644 --- a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs +++ b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs @@ -39,6 +39,125 @@ async fn mainnet_wallet() -> RlnWasmWallet { .expect("mainnet wallet") } +#[wasm_bindgen(inline_js = r#" +export function installIdentityIndexerFixture(regtestGenesis, mainnetGenesis) { + const fixture = { originalFetch: globalThis.fetch, requests: [] }; + globalThis.fetch = async (input) => { + const url = typeof input === "string" ? input : input.url; + fixture.requests.push(url); + const genesis = url === "https://identity-indexer.invalid/regtest/block-height/0" + ? regtestGenesis + : url === "https://identity-indexer.invalid/mainnet/block-height/0" + ? mainnetGenesis + : null; + if (genesis === null) { + throw new Error(`Unexpected identity fixture request: ${url}`); + } + const response = new Response(genesis, { status: 200 }); + // reqwest reads the final URL from fetch responses; synthetic Response omits it. + Object.defineProperty(response, "url", { value: url }); + return response; + }; + return fixture; +} +export function restoreIdentityIndexerFixture(fixture) { + globalThis.fetch = fixture.originalFetch; + return JSON.stringify(fixture.requests); +} +"#)] +extern "C" { + #[wasm_bindgen(js_name = installIdentityIndexerFixture)] + fn install_identity_indexer_fixture(regtest_genesis: &str, mainnet_genesis: &str) -> JsValue; + #[wasm_bindgen(js_name = restoreIdentityIndexerFixture)] + fn restore_identity_indexer_fixture(fixture: &JsValue) -> String; +} + +async fn go_online_with_identity_fixture(wallet: &RlnWasmWallet, network: &str) { + let fixture = install_identity_indexer_fixture( + &bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest) + .block_hash() + .to_string(), + &bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Bitcoin) + .block_hash() + .to_string(), + ); + let online = wallet + .go_online_value(true, format!("https://identity-indexer.invalid/{network}")) + .await; + // Restore fetch before any assertion so a fixture failure cannot affect later tests. + let requests: Vec = + serde_json::from_str(&restore_identity_indexer_fixture(&fixture)).unwrap(); + online.expect("online wallet against the genesis-only fixture"); + assert_eq!( + requests, + [format!( + "https://identity-indexer.invalid/{network}/block-height/0" + )] + ); +} + +#[wasm_bindgen_test(async)] +async fn online_wallet_pubkey_matches_the_live_backend_without_starting_it() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let wallet = RlnWasmWallet::create(&crate::test_utils::test_wallet_data_json()) + .await + .unwrap(); + go_online_with_identity_fixture(&wallet, "regtest").await; + let node = configured_node("regtest"); + node.attach_wallet(&wallet).unwrap(); + let before = test_utils::startup_calls(); + let pure: serde_json::Value = serde_json::from_str(&node.node_pubkey_json().unwrap()).unwrap(); + assert_eq!(test_utils::startup_calls(), before); + + // Exercise the actual pinned KeysManager through the real live object graph. + // This unfunded fixture opens no channels and does not connect to a peer. + let live = node + .lightning_runtime() + .unwrap() + .ldk_runtime + .live_node_pubkey() + .expect("real live object graph with an online RGB wallet"); + assert_eq!(pure["pubkey"], live); + let after = test_utils::startup_calls(); + assert_eq!( + after.get("live_graph").copied().unwrap_or(0), + before.get("live_graph").copied().unwrap_or(0) + 1 + ); +} + +#[wasm_bindgen_test(async)] +async fn mainnet_online_wallet_identity_and_signing_never_start_lightning() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let before = test_utils::startup_calls(); + let wallet = mainnet_wallet().await; + let node = configured_node("mainnet"); + node.attach_wallet(&wallet).unwrap(); + let offline_pubkey = node.node_pubkey_json().unwrap(); + let offline_signature = node.sign_message_json("mainnet identity".into()).unwrap(); + go_online_with_identity_fixture(&wallet, "mainnet").await; + let online_pubkey = node.node_pubkey_json().unwrap(); + assert_ne!(online_pubkey, offline_pubkey); + assert_eq!(online_pubkey, node.node_pubkey_json().unwrap()); + assert_eq!( + offline_signature, + node.sign_message_json("mainnet identity".into()).unwrap() + ); + // A compatible handle retains the same online identity without attaching again. + let shared = configured_node("mainnet"); + assert_eq!(online_pubkey, shared.node_pubkey_json().unwrap()); + assert!(node.lightning.borrow().is_none()); + assert!(shared.lightning.borrow().is_none()); + drop(shared); + drop(node); + assert_eq!(test_utils::startup_calls(), before); +} + #[wasm_bindgen_test(async)] async fn mainnet_lightning_operations_reject_before_runtime_or_state_changes() { crate::test_utils::reset_wasm_runtime_state_for_tests(); From 29da1b9a5cece1aadce90dde45ff48a02a98ca69 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Thu, 1 Oct 2026 14:03:35 +0530 Subject: [PATCH 05/14] Use isolated services in SDK integration tests --- src/test/lib_sdk/README.md | 11 ++++++ src/test/lib_sdk/external_signer.rs | 20 +++++----- src/test/lib_sdk/helpers.rs | 39 +++++++++++++++++++- src/test/lib_sdk/vss_consignment_reimport.rs | 2 +- src/test/lib_sdk/vss_manager_lag.rs | 16 +++----- src/test/lib_sdk/vss_restore.rs | 13 ++----- 6 files changed, 70 insertions(+), 31 deletions(-) diff --git a/src/test/lib_sdk/README.md b/src/test/lib_sdk/README.md index ede39139..7aa16944 100644 --- a/src/test/lib_sdk/README.md +++ b/src/test/lib_sdk/README.md @@ -25,6 +25,17 @@ Run a single scenario: cargo test --features "uniffi,test-utils,vls" --test lib_sdk -- --test-threads=1 ``` +VSS scenarios also require the `vss` feature and a running VSS server. They use +`127.0.0.1:8081` by default. For an isolated server published on another loopback +port, set `RLN_TEST_VSS_PORT` for the test process: + +```sh +RLN_TEST_VSS_PORT=38081 cargo test --features "uniffi,test-utils,vls,vss" --test lib_sdk vss_ -- --test-threads=1 +``` + +Without an explicit port, VSS scenarios skip if the default server is unavailable. +An invalid or unavailable explicit port fails the test instead of skipping it. + Examples of ``: - `success` - `send_receive` diff --git a/src/test/lib_sdk/external_signer.rs b/src/test/lib_sdk/external_signer.rs index 6814ac3b..d0219429 100644 --- a/src/test/lib_sdk/external_signer.rs +++ b/src/test/lib_sdk/external_signer.rs @@ -81,7 +81,7 @@ fn unlock_with_attached_external_signer(node: &SdkNode, announce_alias: &str) { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -176,7 +176,7 @@ fn external_init_unlock_and_restart_same_signer() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -199,7 +199,7 @@ fn external_init_unlock_and_restart_same_signer() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -243,7 +243,7 @@ fn external_restart_with_mismatched_signer_fails_unlock() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -264,7 +264,7 @@ fn external_restart_with_mismatched_signer_fails_unlock() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -611,7 +611,7 @@ fn rgb_native_external_signer_mixed_one_hop_payment_quick() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -759,7 +759,7 @@ fn rgb_native_external_signer_mixed_one_hop_payment_roundtrip() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -925,7 +925,7 @@ fn rgb_native_external_signer_mixed_one_hop_payment_coop_close_settles_to_chain( SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -1102,7 +1102,7 @@ fn external_signer_virtual_channel_survives_restart() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -1321,7 +1321,7 @@ fn external_signer_send_rgb_delivers_consignment_to_recipient() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), diff --git a/src/test/lib_sdk/helpers.rs b/src/test/lib_sdk/helpers.rs index ff56566f..88dccc64 100644 --- a/src/test/lib_sdk/helpers.rs +++ b/src/test/lib_sdk/helpers.rs @@ -36,6 +36,42 @@ pub(crate) const CREATE_UTXOS_FEE_RATE: u64 = 7; pub(crate) const PROXY_ENDPOINT_LOCAL: &str = "rpc://127.0.0.1:3000/json-rpc"; const ELECTRUM_URL: &str = "127.0.0.1:50001"; +/// Keep live VSS tests on loopback while allowing an isolated Compose port mapping. +#[cfg(feature = "vss")] +pub(crate) fn vss_server_addr() -> std::net::SocketAddr { + let port = match std::env::var("RLN_TEST_VSS_PORT") { + Ok(value) => value + .parse::() + .expect("RLN_TEST_VSS_PORT must be an integer between 1 and 65535"), + Err(std::env::VarError::NotPresent) => 8081, + Err(error) => panic!("invalid RLN_TEST_VSS_PORT: {error}"), + }; + assert_ne!(port, 0, "RLN_TEST_VSS_PORT must be between 1 and 65535"); + std::net::SocketAddr::from(([127, 0, 0, 1], port)) +} + +#[cfg(feature = "vss")] +pub(crate) fn vss_server_url() -> String { + format!("http://{}/vss", vss_server_addr()) +} + +/// Default local runs may skip a missing VSS service. Selecting an explicit port commits +/// the run to exercising VSS, so an unavailable endpoint must fail instead of reporting a skip. +#[cfg(feature = "vss")] +pub(crate) fn vss_server_available() -> bool { + let address = vss_server_addr(); + match std::net::TcpStream::connect_timeout(&address, Duration::from_secs(2)) { + Ok(_) => true, + Err(error) => { + assert!( + std::env::var_os("RLN_TEST_VSS_PORT").is_none(), + "VSS server configured by RLN_TEST_VSS_PORT is unavailable at {address}: {error}" + ); + false + } + } +} + static MINER: Lazy> = Lazy::new(|| RwLock::new(Miner { no_mine_count: 0 })); fn repo_root() -> &'static Path { @@ -335,7 +371,8 @@ pub(crate) fn unlock_request(password: &str) -> SdkUnlockRequest { ldk_chain_sync: SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + // Compose publishes RPC on IPv4; localhost may resolve to IPv6 first. + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, indexer_url: Some("127.0.0.1:50001".to_string()), diff --git a/src/test/lib_sdk/vss_consignment_reimport.rs b/src/test/lib_sdk/vss_consignment_reimport.rs index 5068d510..d39ed16a 100644 --- a/src/test/lib_sdk/vss_consignment_reimport.rs +++ b/src/test/lib_sdk/vss_consignment_reimport.rs @@ -5,7 +5,7 @@ //! (`docker compose --profile vss up -d`). use crate::helpers::*; -use crate::vss_manager_lag::{vss_server_available, ManagerFilterProxy}; +use crate::vss_manager_lag::ManagerFilterProxy; use serial_test::serial; use std::{fs, time::Duration}; diff --git a/src/test/lib_sdk/vss_manager_lag.rs b/src/test/lib_sdk/vss_manager_lag.rs index b2af0ab8..d839f4b9 100644 --- a/src/test/lib_sdk/vss_manager_lag.rs +++ b/src/test/lib_sdk/vss_manager_lag.rs @@ -10,18 +10,12 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::{fs, time::Duration}; -const VSS_SERVER_ADDR: &str = "127.0.0.1:8081"; const NODE_A_PORT_OFFSET: u16 = 110; const NODE_B_PORT_OFFSET: u16 = 110; const PASSWORD_A: &str = "nodeApass"; const PASSWORD_B: &str = "nodeBpass"; const MANAGER_VSS_KEY: &[u8] = b"_/_/manager"; -pub(crate) fn vss_server_available() -> bool { - std::net::TcpStream::connect_timeout(&VSS_SERVER_ADDR.parse().unwrap(), Duration::from_secs(2)) - .is_ok() -} - /// VSS proxy that can reject channel-manager-key and/or RGB-backup writes, /// passing all else through. pub(crate) struct ManagerFilterProxy { @@ -33,6 +27,7 @@ pub(crate) struct ManagerFilterProxy { impl ManagerFilterProxy { pub(crate) fn start() -> Self { + let upstream_address = vss_server_addr(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let filter_manager = Arc::new(AtomicBool::new(false)); @@ -48,7 +43,7 @@ impl ManagerFilterProxy { let rgb_flag = Arc::clone(&rgb_flag); let hits = Arc::clone(&hits); std::thread::spawn(move || { - let _ = handle_conn(stream, manager_flag, rgb_flag, hits); + let _ = handle_conn(stream, upstream_address, manager_flag, rgb_flag, hits); }); } }); @@ -123,6 +118,7 @@ fn body_contains(body: &[u8], needle: &[u8]) -> bool { fn handle_conn( mut client: std::net::TcpStream, + upstream_address: std::net::SocketAddr, filter_manager: Arc, filter_rgb_backup: Arc, blocked: Arc, @@ -144,7 +140,7 @@ fn handle_conn( return Ok(()); } // Fresh upstream connection with `Connection: close`: response is EOF-delimited. - let mut upstream = std::net::TcpStream::connect(VSS_SERVER_ADDR)?; + let mut upstream = std::net::TcpStream::connect(upstream_address)?; let mut new_head = String::new(); for line in head_str.split("\r\n") { if line.is_empty() { @@ -338,7 +334,7 @@ fn restore_node_a(setup: &LagSetup) -> (SdkNode, Result<(), rgb_lightning_node:: fn manager_replication_outage_cannot_poison_restore() { ensure_regtest_available(); if !vss_server_available() { - eprintln!("SKIP: VSS server not available at {VSS_SERVER_ADDR}"); + eprintln!("SKIP: VSS server not available at {}", vss_server_addr()); return; } @@ -397,7 +393,7 @@ fn manager_replication_outage_cannot_poison_restore() { fn restore_refuses_when_final_flush_fails() { ensure_regtest_available(); if !vss_server_available() { - eprintln!("SKIP: VSS server not available at {VSS_SERVER_ADDR}"); + eprintln!("SKIP: VSS server not available at {}", vss_server_addr()); return; } diff --git a/src/test/lib_sdk/vss_restore.rs b/src/test/lib_sdk/vss_restore.rs index e69f1e7d..904376a1 100644 --- a/src/test/lib_sdk/vss_restore.rs +++ b/src/test/lib_sdk/vss_restore.rs @@ -13,23 +13,18 @@ use crate::helpers::*; use serial_test::serial; use std::{fs, time::Duration}; -const VSS_URL: &str = "http://127.0.0.1:8081/vss"; const NODE_A_PORT_OFFSET: u16 = 90; const NODE_B_PORT_OFFSET: u16 = 90; const PASSWORD_A: &str = "nodeApass"; const PASSWORD_B: &str = "nodeBpass"; -fn vss_server_available() -> bool { - std::net::TcpStream::connect_timeout(&"127.0.0.1:8081".parse().unwrap(), Duration::from_secs(2)) - .is_ok() -} - #[test] #[serial] fn vss_restores_btc_assets_and_channels_on_fresh_device() { ensure_regtest_available(); + let vss_url = vss_server_url(); if !vss_server_available() { - eprintln!("SKIP: VSS server not available at {VSS_URL}"); + eprintln!("SKIP: VSS server not available at {vss_url}"); return; } @@ -46,7 +41,7 @@ fn vss_restores_btc_assets_and_channels_on_fresh_device() { &node_a_dir, NODE_A_DAEMON_PORT + NODE_A_PORT_OFFSET, NODE_A_PEER_PORT + NODE_A_PORT_OFFSET, - VSS_URL, + &vss_url, ); let node_b = make_node( &node_b_dir, @@ -153,7 +148,7 @@ fn vss_restores_btc_assets_and_channels_on_fresh_device() { &node_a_dir, NODE_A_DAEMON_PORT + NODE_A_PORT_OFFSET, NODE_A_PEER_PORT + NODE_A_PORT_OFFSET, - VSS_URL, + &vss_url, ); // The mnemonic returned by init must round-trip (same seed → same From c828cddba4140da5ab0840eb51c1d8148e703b85 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Thu, 1 Oct 2026 14:03:35 +0530 Subject: [PATCH 06/14] Wait for outbound capacity before reverse test payments --- src/test/lib_sdk/close_coop_standard.rs | 2 ++ src/test/lib_sdk/close_force_standard.rs | 2 ++ src/test/lib_sdk/helpers.rs | 33 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/test/lib_sdk/close_coop_standard.rs b/src/test/lib_sdk/close_coop_standard.rs index bb2eb97b..043ca0d7 100644 --- a/src/test/lib_sdk/close_coop_standard.rs +++ b/src/test/lib_sdk/close_coop_standard.rs @@ -132,6 +132,8 @@ fn close_coop_standard() { 600, 0, ); + // PaymentClaimed can precede the acknowledgment that makes these funds spendable. + wait_for_outbound_capacity(&node_b, channel_id, PAYMENT_MSAT, Duration::from_secs(30)); keysend_with_ln_balance( &node_b, &node_a, diff --git a/src/test/lib_sdk/close_force_standard.rs b/src/test/lib_sdk/close_force_standard.rs index d36c002e..cc61c877 100644 --- a/src/test/lib_sdk/close_force_standard.rs +++ b/src/test/lib_sdk/close_force_standard.rs @@ -90,6 +90,8 @@ fn close_force_standard() { .expect("node A get_channel_id"); keysend(&node_a, node_b_pubkey, None, Some(&asset_id), Some(150)); + // PaymentClaimed can precede the acknowledgment that makes these funds spendable. + wait_for_outbound_capacity(&node_b, channel_id, PAYMENT_MSAT, Duration::from_secs(30)); keysend(&node_b, node_a_pubkey, None, Some(&asset_id), Some(50)); // Mirrors the original test to avoid racing an outdated commitment TX. diff --git a/src/test/lib_sdk/helpers.rs b/src/test/lib_sdk/helpers.rs index 88dccc64..2505a6e1 100644 --- a/src/test/lib_sdk/helpers.rs +++ b/src/test/lib_sdk/helpers.rs @@ -660,6 +660,39 @@ pub(crate) fn wait_for_channel_ready( } } +pub(crate) fn wait_for_outbound_capacity( + node: &SdkNode, + channel_id: lightning::ln::types::ChannelId, + amt_msat: u64, + timeout: Duration, +) { + let deadline = Instant::now() + timeout; + loop { + let channel = node + .list_channels() + .expect("list_channels while waiting for outbound capacity") + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .expect("expected channel while waiting for outbound capacity"); + if channel.ready + && channel.is_usable + && channel.next_outbound_htlc_minimum_msat <= amt_msat + && channel.next_outbound_htlc_limit_msat >= amt_msat + { + return; + } + assert!( + Instant::now() < deadline, + "channel {channel_id} cannot send {amt_msat} msat: ready={}, usable={}, minimum={}, limit={}", + channel.ready, + channel.is_usable, + channel.next_outbound_htlc_minimum_msat, + channel.next_outbound_htlc_limit_msat, + ); + sleep(Duration::from_millis(100)); + } +} + pub(crate) fn wait_for_usable_channels( node: &SdkNode, expected_num_usable_channels: usize, From 03e6b570337223641bb3e98e2582522395a17f7b Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 2 Oct 2026 15:41:50 +0530 Subject: [PATCH 07/14] Remove unrelated test and formatting changes --- bindings/wasm-sdk/src/lib.rs | 9 +-- src/test/lib_sdk/README.md | 11 --- src/test/lib_sdk/close_coop_standard.rs | 2 - src/test/lib_sdk/close_force_standard.rs | 2 - src/test/lib_sdk/external_signer.rs | 20 +++--- src/test/lib_sdk/helpers.rs | 72 +------------------- src/test/lib_sdk/vss_consignment_reimport.rs | 2 +- src/test/lib_sdk/vss_manager_lag.rs | 16 +++-- src/test/lib_sdk/vss_restore.rs | 15 ++-- 9 files changed, 34 insertions(+), 115 deletions(-) diff --git a/bindings/wasm-sdk/src/lib.rs b/bindings/wasm-sdk/src/lib.rs index 3560d765..da198eb6 100644 --- a/bindings/wasm-sdk/src/lib.rs +++ b/bindings/wasm-sdk/src/lib.rs @@ -541,15 +541,10 @@ fn wallet_rgb_proxy_transport_get(idb_key: &str) -> Option Option { - let config = - wallet_rgb_proxy_transport_get(idb_key).or_else(sdk_default_rgb_proxy_transport)?; + let config = wallet_rgb_proxy_transport_get(idb_key).or_else(sdk_default_rgb_proxy_transport)?; match (&config.auth_token, &config.node_id) { (Some(token), Some(node_id)) => { - let separator = if config.endpoint.contains('?') { - '&' - } else { - '?' - }; + let separator = if config.endpoint.contains('?') { '&' } else { '?' }; Some(format!( "{}{}auth_token={}&node_id={}", config.endpoint, diff --git a/src/test/lib_sdk/README.md b/src/test/lib_sdk/README.md index 7aa16944..ede39139 100644 --- a/src/test/lib_sdk/README.md +++ b/src/test/lib_sdk/README.md @@ -25,17 +25,6 @@ Run a single scenario: cargo test --features "uniffi,test-utils,vls" --test lib_sdk -- --test-threads=1 ``` -VSS scenarios also require the `vss` feature and a running VSS server. They use -`127.0.0.1:8081` by default. For an isolated server published on another loopback -port, set `RLN_TEST_VSS_PORT` for the test process: - -```sh -RLN_TEST_VSS_PORT=38081 cargo test --features "uniffi,test-utils,vls,vss" --test lib_sdk vss_ -- --test-threads=1 -``` - -Without an explicit port, VSS scenarios skip if the default server is unavailable. -An invalid or unavailable explicit port fails the test instead of skipping it. - Examples of ``: - `success` - `send_receive` diff --git a/src/test/lib_sdk/close_coop_standard.rs b/src/test/lib_sdk/close_coop_standard.rs index 043ca0d7..bb2eb97b 100644 --- a/src/test/lib_sdk/close_coop_standard.rs +++ b/src/test/lib_sdk/close_coop_standard.rs @@ -132,8 +132,6 @@ fn close_coop_standard() { 600, 0, ); - // PaymentClaimed can precede the acknowledgment that makes these funds spendable. - wait_for_outbound_capacity(&node_b, channel_id, PAYMENT_MSAT, Duration::from_secs(30)); keysend_with_ln_balance( &node_b, &node_a, diff --git a/src/test/lib_sdk/close_force_standard.rs b/src/test/lib_sdk/close_force_standard.rs index cc61c877..d36c002e 100644 --- a/src/test/lib_sdk/close_force_standard.rs +++ b/src/test/lib_sdk/close_force_standard.rs @@ -90,8 +90,6 @@ fn close_force_standard() { .expect("node A get_channel_id"); keysend(&node_a, node_b_pubkey, None, Some(&asset_id), Some(150)); - // PaymentClaimed can precede the acknowledgment that makes these funds spendable. - wait_for_outbound_capacity(&node_b, channel_id, PAYMENT_MSAT, Duration::from_secs(30)); keysend(&node_b, node_a_pubkey, None, Some(&asset_id), Some(50)); // Mirrors the original test to avoid racing an outdated commitment TX. diff --git a/src/test/lib_sdk/external_signer.rs b/src/test/lib_sdk/external_signer.rs index d0219429..6814ac3b 100644 --- a/src/test/lib_sdk/external_signer.rs +++ b/src/test/lib_sdk/external_signer.rs @@ -81,7 +81,7 @@ fn unlock_with_attached_external_signer(node: &SdkNode, announce_alias: &str) { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -176,7 +176,7 @@ fn external_init_unlock_and_restart_same_signer() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -199,7 +199,7 @@ fn external_init_unlock_and_restart_same_signer() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -243,7 +243,7 @@ fn external_restart_with_mismatched_signer_fails_unlock() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -264,7 +264,7 @@ fn external_restart_with_mismatched_signer_fails_unlock() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -611,7 +611,7 @@ fn rgb_native_external_signer_mixed_one_hop_payment_quick() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -759,7 +759,7 @@ fn rgb_native_external_signer_mixed_one_hop_payment_roundtrip() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -925,7 +925,7 @@ fn rgb_native_external_signer_mixed_one_hop_payment_coop_close_settles_to_chain( SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -1102,7 +1102,7 @@ fn external_signer_virtual_channel_survives_restart() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), @@ -1321,7 +1321,7 @@ fn external_signer_send_rgb_delivers_consignment_to_recipient() { SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, Some("127.0.0.1:50001".to_string()), diff --git a/src/test/lib_sdk/helpers.rs b/src/test/lib_sdk/helpers.rs index 2505a6e1..ff56566f 100644 --- a/src/test/lib_sdk/helpers.rs +++ b/src/test/lib_sdk/helpers.rs @@ -36,42 +36,6 @@ pub(crate) const CREATE_UTXOS_FEE_RATE: u64 = 7; pub(crate) const PROXY_ENDPOINT_LOCAL: &str = "rpc://127.0.0.1:3000/json-rpc"; const ELECTRUM_URL: &str = "127.0.0.1:50001"; -/// Keep live VSS tests on loopback while allowing an isolated Compose port mapping. -#[cfg(feature = "vss")] -pub(crate) fn vss_server_addr() -> std::net::SocketAddr { - let port = match std::env::var("RLN_TEST_VSS_PORT") { - Ok(value) => value - .parse::() - .expect("RLN_TEST_VSS_PORT must be an integer between 1 and 65535"), - Err(std::env::VarError::NotPresent) => 8081, - Err(error) => panic!("invalid RLN_TEST_VSS_PORT: {error}"), - }; - assert_ne!(port, 0, "RLN_TEST_VSS_PORT must be between 1 and 65535"); - std::net::SocketAddr::from(([127, 0, 0, 1], port)) -} - -#[cfg(feature = "vss")] -pub(crate) fn vss_server_url() -> String { - format!("http://{}/vss", vss_server_addr()) -} - -/// Default local runs may skip a missing VSS service. Selecting an explicit port commits -/// the run to exercising VSS, so an unavailable endpoint must fail instead of reporting a skip. -#[cfg(feature = "vss")] -pub(crate) fn vss_server_available() -> bool { - let address = vss_server_addr(); - match std::net::TcpStream::connect_timeout(&address, Duration::from_secs(2)) { - Ok(_) => true, - Err(error) => { - assert!( - std::env::var_os("RLN_TEST_VSS_PORT").is_none(), - "VSS server configured by RLN_TEST_VSS_PORT is unavailable at {address}: {error}" - ); - false - } - } -} - static MINER: Lazy> = Lazy::new(|| RwLock::new(Miner { no_mine_count: 0 })); fn repo_root() -> &'static Path { @@ -371,8 +335,7 @@ pub(crate) fn unlock_request(password: &str) -> SdkUnlockRequest { ldk_chain_sync: SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - // Compose publishes RPC on IPv4; localhost may resolve to IPv6 first. - bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_host: "localhost".to_string(), bitcoind_rpc_port: 18443, }, indexer_url: Some("127.0.0.1:50001".to_string()), @@ -660,39 +623,6 @@ pub(crate) fn wait_for_channel_ready( } } -pub(crate) fn wait_for_outbound_capacity( - node: &SdkNode, - channel_id: lightning::ln::types::ChannelId, - amt_msat: u64, - timeout: Duration, -) { - let deadline = Instant::now() + timeout; - loop { - let channel = node - .list_channels() - .expect("list_channels while waiting for outbound capacity") - .into_iter() - .find(|channel| channel.channel_id == channel_id) - .expect("expected channel while waiting for outbound capacity"); - if channel.ready - && channel.is_usable - && channel.next_outbound_htlc_minimum_msat <= amt_msat - && channel.next_outbound_htlc_limit_msat >= amt_msat - { - return; - } - assert!( - Instant::now() < deadline, - "channel {channel_id} cannot send {amt_msat} msat: ready={}, usable={}, minimum={}, limit={}", - channel.ready, - channel.is_usable, - channel.next_outbound_htlc_minimum_msat, - channel.next_outbound_htlc_limit_msat, - ); - sleep(Duration::from_millis(100)); - } -} - pub(crate) fn wait_for_usable_channels( node: &SdkNode, expected_num_usable_channels: usize, diff --git a/src/test/lib_sdk/vss_consignment_reimport.rs b/src/test/lib_sdk/vss_consignment_reimport.rs index d39ed16a..5068d510 100644 --- a/src/test/lib_sdk/vss_consignment_reimport.rs +++ b/src/test/lib_sdk/vss_consignment_reimport.rs @@ -5,7 +5,7 @@ //! (`docker compose --profile vss up -d`). use crate::helpers::*; -use crate::vss_manager_lag::ManagerFilterProxy; +use crate::vss_manager_lag::{vss_server_available, ManagerFilterProxy}; use serial_test::serial; use std::{fs, time::Duration}; diff --git a/src/test/lib_sdk/vss_manager_lag.rs b/src/test/lib_sdk/vss_manager_lag.rs index d839f4b9..b2af0ab8 100644 --- a/src/test/lib_sdk/vss_manager_lag.rs +++ b/src/test/lib_sdk/vss_manager_lag.rs @@ -10,12 +10,18 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::{fs, time::Duration}; +const VSS_SERVER_ADDR: &str = "127.0.0.1:8081"; const NODE_A_PORT_OFFSET: u16 = 110; const NODE_B_PORT_OFFSET: u16 = 110; const PASSWORD_A: &str = "nodeApass"; const PASSWORD_B: &str = "nodeBpass"; const MANAGER_VSS_KEY: &[u8] = b"_/_/manager"; +pub(crate) fn vss_server_available() -> bool { + std::net::TcpStream::connect_timeout(&VSS_SERVER_ADDR.parse().unwrap(), Duration::from_secs(2)) + .is_ok() +} + /// VSS proxy that can reject channel-manager-key and/or RGB-backup writes, /// passing all else through. pub(crate) struct ManagerFilterProxy { @@ -27,7 +33,6 @@ pub(crate) struct ManagerFilterProxy { impl ManagerFilterProxy { pub(crate) fn start() -> Self { - let upstream_address = vss_server_addr(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let filter_manager = Arc::new(AtomicBool::new(false)); @@ -43,7 +48,7 @@ impl ManagerFilterProxy { let rgb_flag = Arc::clone(&rgb_flag); let hits = Arc::clone(&hits); std::thread::spawn(move || { - let _ = handle_conn(stream, upstream_address, manager_flag, rgb_flag, hits); + let _ = handle_conn(stream, manager_flag, rgb_flag, hits); }); } }); @@ -118,7 +123,6 @@ fn body_contains(body: &[u8], needle: &[u8]) -> bool { fn handle_conn( mut client: std::net::TcpStream, - upstream_address: std::net::SocketAddr, filter_manager: Arc, filter_rgb_backup: Arc, blocked: Arc, @@ -140,7 +144,7 @@ fn handle_conn( return Ok(()); } // Fresh upstream connection with `Connection: close`: response is EOF-delimited. - let mut upstream = std::net::TcpStream::connect(upstream_address)?; + let mut upstream = std::net::TcpStream::connect(VSS_SERVER_ADDR)?; let mut new_head = String::new(); for line in head_str.split("\r\n") { if line.is_empty() { @@ -334,7 +338,7 @@ fn restore_node_a(setup: &LagSetup) -> (SdkNode, Result<(), rgb_lightning_node:: fn manager_replication_outage_cannot_poison_restore() { ensure_regtest_available(); if !vss_server_available() { - eprintln!("SKIP: VSS server not available at {}", vss_server_addr()); + eprintln!("SKIP: VSS server not available at {VSS_SERVER_ADDR}"); return; } @@ -393,7 +397,7 @@ fn manager_replication_outage_cannot_poison_restore() { fn restore_refuses_when_final_flush_fails() { ensure_regtest_available(); if !vss_server_available() { - eprintln!("SKIP: VSS server not available at {}", vss_server_addr()); + eprintln!("SKIP: VSS server not available at {VSS_SERVER_ADDR}"); return; } diff --git a/src/test/lib_sdk/vss_restore.rs b/src/test/lib_sdk/vss_restore.rs index 904376a1..11de8573 100644 --- a/src/test/lib_sdk/vss_restore.rs +++ b/src/test/lib_sdk/vss_restore.rs @@ -3,7 +3,7 @@ //! every piece of state came back through VSS. //! //! This guards the "VSS should restore everything" invariant. If anyone -//! removes the RGB-restore call from `start_ldk`, the asset/balance/channel +//! removes the RGB-restore call from `start_node`, the asset/balance/channel //! assertions below fail. //! //! Requires the regtest stack (`./regtest.sh start`) and the VSS server @@ -13,18 +13,23 @@ use crate::helpers::*; use serial_test::serial; use std::{fs, time::Duration}; +const VSS_URL: &str = "http://127.0.0.1:8081/vss"; const NODE_A_PORT_OFFSET: u16 = 90; const NODE_B_PORT_OFFSET: u16 = 90; const PASSWORD_A: &str = "nodeApass"; const PASSWORD_B: &str = "nodeBpass"; +fn vss_server_available() -> bool { + std::net::TcpStream::connect_timeout(&"127.0.0.1:8081".parse().unwrap(), Duration::from_secs(2)) + .is_ok() +} + #[test] #[serial] fn vss_restores_btc_assets_and_channels_on_fresh_device() { ensure_regtest_available(); - let vss_url = vss_server_url(); if !vss_server_available() { - eprintln!("SKIP: VSS server not available at {vss_url}"); + eprintln!("SKIP: VSS server not available at {VSS_URL}"); return; } @@ -41,7 +46,7 @@ fn vss_restores_btc_assets_and_channels_on_fresh_device() { &node_a_dir, NODE_A_DAEMON_PORT + NODE_A_PORT_OFFSET, NODE_A_PEER_PORT + NODE_A_PORT_OFFSET, - &vss_url, + VSS_URL, ); let node_b = make_node( &node_b_dir, @@ -148,7 +153,7 @@ fn vss_restores_btc_assets_and_channels_on_fresh_device() { &node_a_dir, NODE_A_DAEMON_PORT + NODE_A_PORT_OFFSET, NODE_A_PEER_PORT + NODE_A_PORT_OFFSET, - &vss_url, + VSS_URL, ); // The mnemonic returned by init must round-trip (same seed → same From 60a2310c4fa66ad0f4112d5f1da21fd668c518a6 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 2 Oct 2026 16:00:55 +0530 Subject: [PATCH 08/14] Restore non-mainnet startup and test session ownership --- src/ldk.rs | 171 ++++++++++++++++------------------- src/mainnet_startup_tests.rs | 159 +++++++++++++++++++++++++++++--- src/mainnet_vss_tests.rs | 158 ++++++++++++++++++++++++++++++++ src/rgb.rs | 2 +- src/routes.rs | 31 ++++++- src/sdk/mod.rs | 27 ++++++ src/signer/dyn_signer.rs | 2 +- src/signer/external.rs | 4 +- src/signer/remote/mod.rs | 4 +- src/signer/transport.rs | 4 +- src/uniffi_api/README.md | 8 +- 11 files changed, 456 insertions(+), 114 deletions(-) diff --git a/src/ldk.rs b/src/ldk.rs index a218a819..e7c569c0 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -110,6 +110,7 @@ use std::convert::TryInto; use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; use std::net::ToSocketAddrs; +use std::net::{SocketAddr, TcpListener}; use std::path::Path; use std::str::FromStr; #[cfg(test)] @@ -119,6 +120,7 @@ use std::sync::{Arc, Mutex, MutexGuard, RwLock, Weak}; #[cfg(any(test, feature = "vss"))] use std::time::Instant; use std::time::{Duration, SystemTime}; +use time::OffsetDateTime; use tokio::runtime::Handle; use tokio::sync::watch::Sender; use tokio::task::JoinHandle; @@ -185,11 +187,11 @@ use crate::signer::{ }; use crate::swap::{SwapData, SwapInfo}; use crate::utils::{ - connect_peer_if_necessary, description_from_invoice, description_hash_from_invoice, - do_connect_peer, get_current_timestamp, get_max_local_rgb_amount, hex_str, - validate_and_parse_payment_hash, validate_and_parse_payment_preimage, AppState, CommonState, - LightningState, StaticState, UnlockedAppState, FATAL_ERROR, PROXY_ENDPOINT_LOCAL, - PROXY_ENDPOINT_PUBLIC, + check_port_is_available, connect_peer_if_necessary, description_from_invoice, + description_hash_from_invoice, do_connect_peer, get_current_timestamp, + get_max_local_rgb_amount, hex_str, validate_and_parse_payment_hash, + validate_and_parse_payment_preimage, AppState, CommonState, LightningState, StaticState, + UnlockedAppState, FATAL_ERROR, PROXY_ENDPOINT_LOCAL, PROXY_ENDPOINT_PUBLIC, }; const RGB_TRANSFER_CHAN_EXPIRATION_SECS: u64 = 86400; @@ -345,8 +347,6 @@ pub(crate) struct LdkBackgroundServices { peer_manager: Arc, bp_exit: Sender<()>, background_processor: Option>>, - shutdown: CancellationToken, - tasks: Vec>, } #[derive(Clone, Debug)] @@ -5413,42 +5413,6 @@ async fn start_lightning( #[cfg(not(feature = "vss"))] let bp_kv_store: BpKvStore = KVStoreSyncWrapper(Arc::clone(&kv_store)); - // Regularly broadcast our node_announcement. This is only required (or possible) if we have - // some public channels. - let mut ldk_announced_listen_addr = Vec::new(); - for addr in &unlock_request.announce_addresses { - match SocketAddress::from_str(addr) { - Ok(sa) => { - ldk_announced_listen_addr.push(sa); - } - Err(_) => { - return Err(APIError::InvalidAnnounceAddresses(format!( - "failed to parse address '{addr}'" - ))) - } - } - } - let ldk_announced_node_name = match &unlock_request.announce_alias { - Some(s) => { - if s.len() > 32 { - return Err(APIError::InvalidAnnounceAlias(s!( - "cannot be longer than 32 bytes" - ))); - } - let mut bytes = [0; 32]; - bytes[..s.len()].copy_from_slice(s.as_bytes()); - bytes - } - None => [0; 32], - }; - - let listener = crate::utils::bind_first_available(&[ - format!("[::]:{ldk_peer_listening_port}"), - format!("0.0.0.0:{ldk_peer_listening_port}"), - ]) - .await - .map_err(|e| APIError::Unexpected(format!("failed to bind Lightning peer listener: {e}")))?; - // Initialize the chain backend for the requested sync mode let handle = tokio::runtime::Handle::current(); let ChainSetup { @@ -6120,42 +6084,36 @@ async fn start_lightning( // ## Running LDK // Initialize networking - let output_sweeper: Arc = Arc::new(output_sweeper); - // Finish fallible initial sync before any protocol worker starts accepting peers. - #[cfg(feature = "transaction-sync")] - #[allow(irrefutable_let_patterns)] - if let ChainBackend::TransactionSync { tx_sync, .. } = &backend { - let confirmables: Vec> = vec![ - channel_manager.clone(), - chain_monitor.clone(), - output_sweeper.clone(), - ]; - sync_chain_data(tx_sync.clone(), confirmables) - .await - .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; - } let peer_manager_connection_handler = peer_manager.clone(); + let listening_port = ldk_peer_listening_port; let stop_processing = Arc::new(AtomicBool::new(false)); - let shutdown = CancellationToken::new(); - let listener_shutdown = shutdown.clone(); - let listener_task = tokio::spawn(async move { + let stop_listen = Arc::clone(&stop_processing); + tokio::spawn(async move { + // Dual-stack when available; hosts with IPv6 disabled fall back to IPv4. + let listener = crate::utils::bind_first_available(&[ + format!("[::]:{listening_port}"), + format!("0.0.0.0:{listening_port}"), + ]) + .await + .expect("Failed to bind to listen port - is something else already listening on it?"); loop { - let accepted = tokio::select! { - biased; - _ = listener_shutdown.cancelled() => break, - accepted = listener.accept() => accepted, - }; - let tcp_stream = accepted - .expect("Lightning listener failed to accept a connection") - .0; let peer_mgr = peer_manager_connection_handler.clone(); + let tcp_stream = listener.accept().await.unwrap().0; + if stop_listen.load(Ordering::Acquire) { + return; + } tokio::spawn(async move { - lightning_net_tokio::setup_inbound(peer_mgr, tcp_stream.into_std().unwrap()).await; + lightning_net_tokio::setup_inbound( + peer_mgr.clone(), + tcp_stream.into_std().unwrap(), + ) + .await; }); } }); // Connect and Disconnect Blocks + let output_sweeper: Arc = Arc::new(output_sweeper); let stop_listen = Arc::clone(&stop_processing); match backend { #[cfg(feature = "block-sync")] @@ -6192,6 +6150,10 @@ async fn start_lightning( chain_monitor.clone(), output_sweeper.clone(), ]; + // bring everything up to the current tip before starting to serve + sync_chain_data(tx_sync.clone(), confirmables.clone()) + .await + .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; tokio::spawn(async move { loop { if stop_listen.load(Ordering::Acquire) { @@ -6616,6 +6578,35 @@ async fn start_lightning( }); } + // Regularly broadcast our node_announcement. This is only required (or possible) if we have + // some public channels. + let mut ldk_announced_listen_addr = Vec::new(); + for addr in unlock_request.announce_addresses { + match SocketAddress::from_str(&addr) { + Ok(sa) => { + ldk_announced_listen_addr.push(sa); + } + Err(_) => { + return Err(APIError::InvalidAnnounceAddresses(format!( + "failed to parse address '{addr}'" + ))) + } + } + } + let ldk_announced_node_name = match unlock_request.announce_alias { + Some(s) => { + if s.len() > 32 { + return Err(APIError::InvalidAnnounceAlias(s!( + "cannot be longer than 32 bytes" + ))); + } + let mut bytes = [0; 32]; + bytes[..s.len()].copy_from_slice(s.as_bytes()); + bytes + } + None => [0; 32], + }; + // cleanup the buffers of RGB file transfers a peer started and never finished let sweep_handler = Arc::clone(&rgb_file_transfer_handler); let stop_sweep = Arc::clone(&stop_processing); @@ -6633,24 +6624,17 @@ async fn start_lightning( let peer_man = Arc::clone(&peer_manager); let chan_man = Arc::clone(&channel_manager); - let announcement_shutdown = shutdown.clone(); let announce_initial_delay_secs = static_state.config.node.announce_initial_delay_secs; let announce_refresh_interval_secs = static_state.config.node.announce_refresh_interval_secs; - let announcement_task = tokio::spawn(async move { + tokio::spawn(async move { // First wait until we have some peers and maybe have opened a channel. - tokio::select! { - _ = announcement_shutdown.cancelled() => return, - _ = tokio::time::sleep(Duration::from_secs(announce_initial_delay_secs)) => {} - } + tokio::time::sleep(Duration::from_secs(announce_initial_delay_secs)).await; // Then, update our announcement periodically to keep it fresh but avoid unnecessary churn // in the global gossip network. let mut interval = tokio::time::interval(Duration::from_secs(announce_refresh_interval_secs)); loop { - tokio::select! { - _ = announcement_shutdown.cancelled() => break, - _ = interval.tick() => {} - } + interval.tick().await; // Don't bother trying to announce if we don't have any public channls, though our // peers should drop such an announcement anyway. Note that announcement may not // propagate until we have a channel with 6+ confirmations. @@ -6683,8 +6667,6 @@ async fn start_lightning( peer_manager: peer_manager.clone(), bp_exit, background_processor: Some(background_processor), - shutdown, - tasks: vec![listener_task, announcement_task], }), Arc::new(UnlockedAppState { common, @@ -6724,7 +6706,6 @@ impl AppState { ldk_background_services .stop_processing .store(true, Ordering::Release); - ldk_background_services.shutdown.cancel(); ldk_background_services.gossip_shutdown.notify_one(); ldk_background_services.peer_manager.disconnect_all_peers(); @@ -6992,15 +6973,23 @@ pub(crate) async fn stop_node(app_state: Arc) { release_vss_fence(Arc::clone(&common.kv_store), teardown).await; } - if let Some(services) = lightning { - for mut task in services.tasks { - match tokio::time::timeout(Duration::from_secs(5), &mut task).await { - Ok(Ok(())) => {} - Ok(Err(error)) => tracing::error!(%error, "Lightning task failed during shutdown"), - Err(_) => { - task.abort(); - tracing::warn!("Lightning task did not exit after cancellation"); - } + // Only an owned Lightning listener needs the baseline wakeup and port-release wait. + // A wallet-only or already-stopped session must not contact an unused peer port. + if lightning.is_some() { + // connect to the peer port so it can be released + let peer_port = app_state.static_state.ldk_peer_listening_port; + let sock_addr = SocketAddr::from(([127, 0, 0, 1], peer_port)); + let _ = check_port_is_available(peer_port); + // check the peer port has been released + let t_0 = OffsetDateTime::now_utc(); + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + if TcpListener::bind(sock_addr).is_ok() { + break; + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 10.0 { + tracing::error!("LDK peer port {peer_port} was not released within 10s"); + break; } } } diff --git a/src/mainnet_startup_tests.rs b/src/mainnet_startup_tests.rs index 1b60d396..02ba2e30 100644 --- a/src/mainnet_startup_tests.rs +++ b/src/mainnet_startup_tests.rs @@ -15,12 +15,15 @@ use bitcoin::consensus::encode::serialize_hex; use rgb_lib::BitcoinNetwork; use serde_json::{json, Value}; use std::{ + future::{poll_fn, Future}, sync::{Arc, Mutex}, + task::Poll, time::Duration, }; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::TcpListener, + sync::Semaphore, task::JoinHandle, }; @@ -28,10 +31,31 @@ const PASSWORD: &str = "mainnet-test-password"; const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +struct RequestGate { + entered: Semaphore, + resume: Semaphore, +} +impl RequestGate { + fn new() -> Arc { + Arc::new(Self { + entered: Semaphore::new(0), + resume: Semaphore::new(0), + }) + } + async fn wait(&self) { + tokio::time::timeout(Duration::from_secs(10), self.entered.acquire()) + .await + .expect("startup must reach the indexer") + .unwrap() + .forget(); + } +} + pub(crate) struct Indexer { pub(crate) url: String, requests: Arc>>, unexpected: Arc>>, + next_request_gate: Arc>>>, task: JoinHandle<()>, } impl Drop for Indexer { @@ -45,6 +69,8 @@ impl Indexer { let url = format!("tcp://{}", listener.local_addr().unwrap()); let requests = Arc::new(Mutex::new(Vec::new())); let unexpected = Arc::new(Mutex::new(Vec::new())); + let next_request_gate: Arc>>> = Default::default(); + let gate = next_request_gate.clone(); let seen = Arc::clone(&requests); let errors = Arc::clone(&unexpected); let task = tokio::spawn(async move { @@ -53,11 +79,17 @@ impl Indexer { let (stream, _) = listener.accept().await.unwrap(); let seen = Arc::clone(&seen); let errors = Arc::clone(&errors); + let gate = gate.clone(); connections.spawn(async move { let (reader, mut writer) = stream.into_split(); let mut lines = BufReader::new(reader).lines(); while let Ok(Some(line)) = lines.next_line().await { let request: Value = serde_json::from_str(&line).unwrap(); + let paused = gate.lock().unwrap().take(); + if let Some(paused) = paused { + paused.entered.add_permits(1); + paused.resume.acquire().await.unwrap().forget(); + } let response = |request: &Value| { let method = request["method"].as_str().unwrap(); seen.lock().unwrap().push(method.into()); @@ -89,6 +121,7 @@ impl Indexer { url, requests, unexpected, + next_request_gate, task, } } @@ -136,14 +169,17 @@ impl Fixture { })); let proxy_task = tokio::spawn(async move { axum::serve(proxy_listener, proxy_router).await.unwrap() }); + // Mainnet keeps an occupied trap port; non-mainnet must bind and release a real port. + let peer_port = if network == BitcoinNetwork::Mainnet { + peer.local_addr().unwrap().port() + } else { + let available = TcpListener::bind("127.0.0.1:0").await.unwrap(); + available.local_addr().unwrap().port() + }; let state = start_daemon(&UserArgs { storage_dir_path: directory.path().to_path_buf(), daemon_listening_port: 0, - ldk_peer_listening_port: if network == BitcoinNetwork::Mainnet { - peer.local_addr().unwrap().port() - } else { - 0 - }, + ldk_peer_listening_port: peer_port, network, max_media_upload_size_mb: 1, max_aggregated_media_size_per_channel_mb: 1, @@ -447,17 +483,17 @@ async fn mainnet_legacy_state_refusal_preserves_bytes_and_does_not_contact_index #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn canceled_sdk_caller_does_not_abandon_wallet_startup() { let fixture = Fixture::new().await; + let gate = RequestGate::new(); + *fixture.indexer.next_request_gate.lock().unwrap() = Some(gate.clone()); let state = fixture.state.clone(); let request = fixture.request(); let caller = tokio::spawn(async move { sdk::unlock(state, request).await }); - tokio::time::timeout(Duration::from_secs(10), async { - while fixture.indexer.requests.lock().unwrap().is_empty() { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); + gate.wait().await; + assert!(*fixture.state.changing_state.lock().unwrap()); + assert!(fixture.state.unlocked_app_state.lock().await.is_none()); caller.abort(); + assert!(caller.await.unwrap_err().is_cancelled()); + gate.resume.add_permits(1); tokio::time::timeout(Duration::from_secs(10), async { while *fixture.state.changing_state.lock().unwrap() { tokio::task::yield_now().await; @@ -471,6 +507,87 @@ async fn canceled_sdk_caller_does_not_abandon_wallet_startup() { .unwrap(); } +/// Poll while the mutex is owned so the transition begins after the initial admission check. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_queued_wallet_calls_recheck_transition_after_mutex() { + let fixture = Fixture::new().await; + sdk::unlock(fixture.state.clone(), fixture.request()) + .await + .unwrap(); + let guard = fixture.state.unlocked_app_state.lock().await; + let mut sdk_call = std::pin::pin!(sdk::address(fixture.state.clone())); + let mut rest_call = + std::pin::pin!(routes::address(axum::extract::State(fixture.state.clone()))); + poll_fn(|cx| { + assert!(sdk_call.as_mut().poll(cx).is_pending()); + assert!(rest_call.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + *fixture.state.changing_state.lock().unwrap() = true; + drop(guard); + assert!(matches!(sdk_call.await, Err(APIError::ChangingState))); + assert!(matches!(rest_call.await, Err(APIError::ChangingState))); + *fixture.state.changing_state.lock().unwrap() = false; + assert!(sdk::address(fixture.state.clone()) + .await + .unwrap() + .address + .starts_with("bc1")); + let _ = routes::lock(axum::extract::State(fixture.state.clone())) + .await + .unwrap(); +} + +#[cfg(feature = "uniffi")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_shutdown_waits_for_inflight_unlock_publication() { + let fixture = Fixture::new().await; + let gate = RequestGate::new(); + *fixture.indexer.next_request_gate.lock().unwrap() = Some(gate.clone()); + let state = fixture.state.clone(); + let request = fixture.request(); + let unlock = tokio::spawn(async move { sdk::unlock(state, request).await }); + gate.wait().await; + let handle = crate::NodeHandle::from_app_state(fixture.state.clone()); + let shutdown = tokio::spawn(async move { handle.shutdown().await }); + fixture.state.cancel_token.cancelled().await; + assert!(*fixture.state.changing_state.lock().unwrap()); + assert!( + !shutdown.is_finished(), + "shutdown must wait for the unpublished session" + ); + gate.resume.add_permits(1); + tokio::time::timeout(Duration::from_secs(15), unlock) + .await + .unwrap() + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(15), shutdown) + .await + .unwrap() + .unwrap(); + assert!(!*fixture.state.changing_state.lock().unwrap()); + assert!(fixture.state.unlocked_app_state.lock().await.is_none()); + assert!(fixture + .state + .ldk_background_services + .lock() + .unwrap() + .is_none()); + assert!(matches!( + sdk::unlock(fixture.state.clone(), fixture.request()).await, + Err(APIError::Unexpected(detail)) if detail == "Node is shutting down" + )); + assert!(fixture.state.unlocked_app_state.lock().await.is_none()); + assert!(!*fixture.state.changing_state.lock().unwrap()); + assert!( + tokio::time::timeout(Duration::from_millis(20), fixture.peer.accept()) + .await + .is_err() + ); +} + #[cfg(all(feature = "uniffi", feature = "vls"))] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn mainnet_attached_and_native_signer_unlock_keep_strict_policy_without_channel_calls() { @@ -585,6 +702,20 @@ async fn regtest_starts_lightning_and_restores_after_lock() { .lock() .unwrap() .is_some()); + let peer_address = ( + "127.0.0.1", + fixture.state.static_state.ldk_peer_listening_port, + ); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if tokio::net::TcpStream::connect(peer_address).await.is_ok() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("non-mainnet listener must accept connections"); let info = sdk::node_info(fixture.state.clone()).await.unwrap(); if let Some(expected) = &node_id { assert_eq!(&info.pubkey, expected); @@ -622,6 +753,10 @@ async fn regtest_starts_lightning_and_restores_after_lock() { .lock() .unwrap() .is_none()); + let released = TcpListener::bind(peer_address) + .await + .expect("lock must release the peer port before restart"); + drop(released); let store = crate::kv_store::SeaOrmKvStore::from_connection(fixture.state.db()); assert!(!store .read( diff --git a/src/mainnet_vss_tests.rs b/src/mainnet_vss_tests.rs index ab0bd4d9..754a2acd 100644 --- a/src/mainnet_vss_tests.rs +++ b/src/mainnet_vss_tests.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, HashSet}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use axum::body::Bytes; use axum::extract::State; @@ -13,6 +14,7 @@ use axum::{Json, Router}; use rgb_lib::BitcoinNetwork; use serde_json::{json, Value}; use tokio::net::TcpListener; +use tokio::sync::Semaphore; use tokio::task::JoinHandle; use vss_client::prost::Message; use vss_client::types::{ @@ -47,10 +49,17 @@ struct Request { authenticated: bool, } +struct BackupGate { + store: String, + entered: Semaphore, + resume: Semaphore, +} + #[derive(Default)] struct ServerState { stores: BTreeMap, requests: Vec, + backup_gate: Option>, } impl ServerState { @@ -131,6 +140,28 @@ async fn handle( headers: HeaderMap, body: Bytes, ) -> Response { + // Pause a selected real RGB backup before committing its protobuf transaction. + let gate = if uri.path() == "/vss/putObjects" { + let request = PutObjectRequest::decode(body.clone()).unwrap(); + let mut state = state.lock().unwrap(); + if state.backup_gate.as_ref().is_some_and(|gate| { + request.store_id == gate.store + && request + .transaction_items + .iter() + .any(|item| item.key == "backup/data") + }) { + state.backup_gate.take() + } else { + None + } + } else { + None + }; + if let Some(gate) = gate { + gate.entered.add_permits(1); + gate.resume.acquire().await.unwrap().forget(); + } let mut state = state.lock().unwrap(); let authenticated = headers.contains_key("authorization"); match uri.path() { @@ -539,3 +570,130 @@ async fn mainnet_vss_remote_only_manager_on_later_page_is_preserved_and_fence_re .filter(|r| r.method != "list") .all(|r| r.keys.iter().chain(&r.deletes).all(|key| key == FENCE))); } + +/// The second stop has no session ownership while the first is still stopping its store. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_vss_concurrent_stop_keeps_fence_with_teardown_owner() { + let server = Server::new().await; + let wallet = Wallet::new(&server).await; + sdk::unlock(wallet.state.clone(), wallet.request()) + .await + .unwrap(); + let common = wallet + .state + .unlocked_app_state + .lock() + .await + .as_ref() + .unwrap() + .common + .clone(); + let node_store = store_id(); + let fence = server.rows(&node_store)[FENCE].clone(); + let entered = Arc::new(Semaphore::new(0)); + let signal = entered.clone(); + let (resume, wait) = std::sync::mpsc::channel(); + let wait = Mutex::new(wait); + common.kv_store.set_before_stop_gate_hook(Arc::new(move || { + signal.add_permits(1); + wait.lock() + .unwrap() + .recv_timeout(Duration::from_secs(10)) + .expect("release stop gate"); + })); + let first = tokio::spawn(crate::ldk::stop_node(wallet.state.clone())); + tokio::time::timeout(Duration::from_secs(10), entered.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + assert!(wallet.state.unlocked_app_state.lock().await.is_none()); + assert!(!first.is_finished()); + tokio::time::timeout( + Duration::from_secs(1), + crate::ldk::stop_node(wallet.state.clone()), + ) + .await + .expect("second stop owns no session"); + assert_eq!(server.rows(&node_store)[FENCE], fence); + resume.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(10), first) + .await + .unwrap() + .unwrap(); + assert!(!server.rows(&node_store).contains_key(FENCE)); + assert!(common.persistence_worker.lock().unwrap().is_none()); +} + +/// Lock cannot hand over the fence until an already admitted backup commits, even if its caller exits. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mainnet_vss_lock_waits_for_canceled_manual_backup_callers() { + let server = Server::new().await; + let wallet = Wallet::new(&server).await; + let node_store = store_id(); + let rgb_store = format!("{node_store}_rgb"); + for use_rest in [false, true] { + sdk::unlock(wallet.state.clone(), wallet.request()) + .await + .unwrap(); + let previous = sdk::vss_backup(wallet.state.clone()).await.unwrap(); + let gate = Arc::new(BackupGate { + store: rgb_store.clone(), + entered: Semaphore::new(0), + resume: Semaphore::new(0), + }); + server.state.lock().unwrap().backup_gate = Some(gate.clone()); + let state = wallet.state.clone(); + let backup = tokio::spawn(async move { + if use_rest { + routes::vss_backup(State(state)).await.map(|_| ()) + } else { + sdk::vss_backup(state).await.map(|_| ()) + } + }); + tokio::time::timeout(Duration::from_secs(10), gate.entered.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + assert!( + wallet.state.unlocked_app_state.try_lock().is_err(), + "backup must retain the session guard" + ); + let state = wallet.state.clone(); + let lock = tokio::spawn(async move { routes::lock(State(state)).await }); + backup.abort(); + assert!(backup.await.unwrap_err().is_cancelled()); + assert!(!lock.is_finished()); + assert!(server.rows(&node_store).contains_key(FENCE)); + gate.resume.add_permits(1); + let _ = tokio::time::timeout(Duration::from_secs(15), lock) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(server.rows(&rgb_store)["backup/data"].version > previous); + assert!(!server.rows(&node_store).contains_key(FENCE)); + assert!(wallet.state.unlocked_app_state.lock().await.is_none()); + assert!(!*wallet.state.changing_state.lock().unwrap()); + let state = server.state.lock().unwrap(); + let backup_position = state + .requests + .iter() + .rposition(|r| { + r.store == rgb_store + && r.method == "put" + && r.keys.iter().any(|key| key == "backup/data") + }) + .unwrap(); + let release_position = state + .requests + .iter() + .rposition(|r| r.store == node_store && r.deletes.iter().any(|key| key == FENCE)) + .unwrap(); + assert!( + backup_position < release_position, + "RGB backup must commit before fence release" + ); + } +} diff --git a/src/rgb.rs b/src/rgb.rs index 88d43898..73455a31 100644 --- a/src/rgb.rs +++ b/src/rgb.rs @@ -636,7 +636,7 @@ impl RgbLibWalletWrapper { } /// Returns the wallet's configured `VssBackupClient`, if any. This is the - /// client constructed by `configure_vss_backup` in `start_ldk`; callers + /// client constructed by `configure_vss_backup` during common wallet startup; callers /// (e.g. the manual `/vssbackup` route) reuse it instead of building a /// duplicate with the same configuration. #[cfg(feature = "vss")] diff --git a/src/routes.rs b/src/routes.rs index 0b19e3be..3e17705a 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4130,9 +4130,9 @@ pub(crate) async fn lock( } }; - tracing::debug!("Stopping LDK..."); + tracing::debug!("Stopping node..."); stop_node(state.clone()).await; - tracing::debug!("LDK stopped"); + tracing::debug!("Node stopped"); state.update_unlocked_app_state(None).await; @@ -6128,6 +6128,33 @@ mod changing_state_guard_tests { use super::state_mocks::mock_state_with_auth; use super::ChangingStateGuard; + // Poll the actual admission helper behind the session mutex; no sleep or spawned-task race. + #[tokio::test] + async fn queued_locked_admission_rechecks_transition() { + use std::future::{poll_fn, Future}; + use std::task::Poll; + let state = mock_state_with_auth(None).await; + let storage_dir = state.static_state.storage_dir_path.clone(); + let guard = state.unlocked_app_state.lock().await; + assert!(guard.is_none()); + let mut waiting = std::pin::pin!(state.check_locked()); + poll_fn(|cx| { + assert!(waiting.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + *state.get_changing_state() = true; + drop(guard); + assert!(matches!( + waiting.await, + Err(crate::error::APIError::ChangingState) + )); + assert!(state.unlocked_app_state.lock().await.is_none()); + assert!(state.ldk_background_services.lock().unwrap().is_none()); + *state.get_changing_state() = false; + std::fs::remove_dir_all(storage_dir).unwrap(); + } + #[tokio::test] async fn sets_and_clears_the_flag() { let state = mock_state_with_auth(None).await; diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs index de06f278..020cf33c 100644 --- a/src/sdk/mod.rs +++ b/src/sdk/mod.rs @@ -4714,6 +4714,33 @@ mod tests { use tokio::sync::Mutex as TokioMutex; use tokio_util::sync::CancellationToken; + // Poll the actual admission helper behind the session mutex; no sleep or spawned-task race. + #[tokio::test] + async fn queued_locked_admission_rechecks_transition() { + use std::future::{poll_fn, Future}; + use std::task::Poll; + let state = mock_locked_state(); + let storage_dir = state.static_state.storage_dir_path.clone(); + let guard = state.unlocked_app_state.lock().await; + assert!(guard.is_none()); + let mut waiting = std::pin::pin!(check_locked(&state)); + poll_fn(|cx| { + assert!(waiting.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + *state.changing_state.lock().unwrap() = true; + drop(guard); + assert!(matches!( + waiting.await, + Err(crate::error::APIError::ChangingState) + )); + assert!(state.unlocked_app_state.lock().await.is_none()); + assert!(state.ldk_background_services.lock().unwrap().is_none()); + *state.changing_state.lock().unwrap() = false; + std::fs::remove_dir_all(storage_dir).unwrap(); + } + #[test] fn verify_message_signature_accepts_known_lightning_vector_and_rejects_tampering() { let message = b"is this compatible?"; diff --git a/src/signer/dyn_signer.rs b/src/signer/dyn_signer.rs index 8f49d8ae..5c630e60 100644 --- a/src/signer/dyn_signer.rs +++ b/src/signer/dyn_signer.rs @@ -249,7 +249,7 @@ impl EntropySource for DynRlnSigner { match self { Self::Internal(km) => km.get_secure_random_bytes(), // External matches `ExternalSigner` policy: LDK trait entropy is system-only (see - // `crate::ldk::start_ldk` / `UnlockedAppState::entropy_source`). + // `crate::ldk::start_node` / `CommonState::entropy_source`). Self::External(es) => es.get_secure_random_bytes(), } } diff --git a/src/signer/external.rs b/src/signer/external.rs index d75ac1af..295d1e0e 100644 --- a/src/signer/external.rs +++ b/src/signer/external.rs @@ -177,10 +177,10 @@ mod tests { /// Bootstrap currently carries only public signer identity/config, while runtime signer /// operations fetch all signing-capable material through the external signer backend. /// -/// Production unlock builds this via [`ExternalSigner::from_attachment`]. `crate::ldk::start_ldk` +/// Production unlock builds this via [`ExternalSigner::from_attachment`]. `crate::ldk::start_node` /// does not construct a local [`lightning::sign::KeysManager`] when the active signer is external. /// When LDK passes this type as [`lightning::sign::EntropySource`], randomness is drawn from -/// [`SystemEntropySource`] (same OsRng path as `start_ldk`'s `ldk_entropy_source`) so channel-scoped +/// [`SystemEntropySource`] (same OsRng path as `start_lightning`'s `ldk_entropy_source`) so channel-scoped /// randomness never depends on host RPC latency or policy. Host-backed signing uses [`NodeSigner`], /// [`SignerProvider`], and related traits only. #[derive(Clone)] diff --git a/src/signer/remote/mod.rs b/src/signer/remote/mod.rs index 550e6df4..e26ad353 100644 --- a/src/signer/remote/mod.rs +++ b/src/signer/remote/mod.rs @@ -80,7 +80,7 @@ pub(crate) struct DaemonEnvelopeTransport { /// Pre-encoded `SignerRequest::Bootstrap` envelope, replayed by [`Self::reconnect`]. bootstrap_request: Vec, /// Marked down when a call observes a broken connection and up when one re-establishes it. - /// Exposed via [`ExternalSignerTransport::link_watch`] so `start_ldk` drives `signer_unblocked` + /// Exposed via [`ExternalSignerTransport::link_watch`] so `start_lightning` drives `signer_unblocked` /// exactly while there's an outage to recover from, instead of polling forever. link: Arc, } @@ -411,7 +411,7 @@ mod tests { ok.expect("reconnect"); } - /// The event-driven half of the `signer_unblocked` resilience fix in `start_ldk`: a genuine + /// The event-driven half of the `signer_unblocked` resilience fix in `start_lightning`: a genuine /// reconnect (recovering from a dropped connection, not the initial connect) must fire the /// link watch's `changed()` signal with `is_connected` back to `true`, so a waiting task can /// react immediately instead of waiting out a polling interval. diff --git a/src/signer/transport.rs b/src/signer/transport.rs index 0b80b28b..84c42da5 100644 --- a/src/signer/transport.rs +++ b/src/signer/transport.rs @@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; /// Health of a remote signer link, shared between the transport (which reports transitions) and the -/// `signer_unblocked` driver loop in `start_ldk` (which watches them). State-based rather than a bare +/// `signer_unblocked` driver loop in `start_lightning` (which watches them). State-based rather than a bare /// notification so the watcher can always re-check `is_connected` after a wake-up — `Notify` permits /// cap at one, so a bare notification cannot by itself distinguish "went down" from "went down and /// came back" while the watcher slept. @@ -13,7 +13,7 @@ pub(crate) struct SignerLinkWatch { } // The producer side (`new_connected`/`mark_*`) is only exercised by the remote-signer transport; -// the consumer side (`is_connected`/`changed`) is used unconditionally by `start_ldk`. +// the consumer side (`is_connected`/`changed`) is used by `start_lightning`. #[cfg_attr(not(feature = "remote-signer"), allow(dead_code))] impl SignerLinkWatch { /// A link that starts out connected (transports establish their connection eagerly). diff --git a/src/uniffi_api/README.md b/src/uniffi_api/README.md index 1c3a7d79..b37fdda1 100644 --- a/src/uniffi_api/README.md +++ b/src/uniffi_api/README.md @@ -49,7 +49,13 @@ Persisted mainnet Lightning state causes `RlnError::MainnetLightningState` durin The appended error variant preserves existing error ordinals. This includes opaque empty snapshots from older on-chain-only wallets; no state is deleted or resumed. See [mainnet startup and recovery requirements](../../README.md) before upgrading an -existing wallet. Supported non-mainnet networks retain their existing behavior. +existing wallet. Lightning remains available on supported non-mainnet networks with +the same wallet and signing policies. + +On all networks, canceling an asynchronous Rust SDK unlock caller does not abandon +the unlock operation. Shutdown waits for an in-progress unlock to complete before +stopping the node, and a shut-down handle cannot unlock again. Lock and shutdown also +wait for an already admitted manual wallet backup before releasing its storage fence. ## Dependency layering From 603777138b857a82da9ec8daa96f19e56a814f96 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 2 Oct 2026 16:02:31 +0530 Subject: [PATCH 09/14] Fix cold browser runtime initialization --- bindings/wasm-sdk/src/ldk_live_backend.rs | 13 +- bindings/wasm-sdk/src/ln_node.rs | 48 ++-- .../src/tests/mainnet_lightning_tests.rs | 214 ++++++++++++++++++ 3 files changed, 259 insertions(+), 16 deletions(-) diff --git a/bindings/wasm-sdk/src/ldk_live_backend.rs b/bindings/wasm-sdk/src/ldk_live_backend.rs index 19a6d628..0dabbaff 100644 --- a/bindings/wasm-sdk/src/ldk_live_backend.rs +++ b/bindings/wasm-sdk/src/ldk_live_backend.rs @@ -79,7 +79,8 @@ thread_local! { /// Register the RGB wallet for a given LDK runtime key. /// /// Must be called (with a wallet that has already called `go_online`) before the LDK object -/// graph is first built for that runtime. Called automatically from `attach_wallet_shared`. +/// graph is first built for that runtime. Node preparation registers the shared wallet; +/// subsequent `attach_wallet_shared` calls update an already prepared runtime. pub fn register_rgb_wallet_for_runtime( runtime_key: &str, wallet: Rc>, @@ -114,6 +115,14 @@ fn virtual_channels_v0_enabled(runtime_key: &str) -> bool { .unwrap_or(false) } +#[cfg(test)] +pub(crate) fn registered_node_config_for_tests(runtime_key: &str) -> (bool, Option) { + ( + RGB_WALLET_REGISTRY.with(|registry| registry.borrow().contains_key(runtime_key)), + VIRTUAL_CHANNELS_V0_REGISTRY.with(|registry| registry.borrow().get(runtime_key).copied()), + ) +} + // --------------------------------------------------------------------------- // Selecting the node's Bitcoin network // --------------------------------------------------------------------------- @@ -145,7 +154,7 @@ fn virtual_channels_v0_enabled(runtime_key: &str) -> bool { // matching the historical hardcoded behaviour. thread_local! { /// Maps LDK runtime_key → the Bitcoin network the node operates on. - /// Set from `attach_wallet_shared` (derived from the attached RGB wallet); consumed when the live + /// Set during node runtime preparation from its configured/adopted network; consumed when the live /// backend builds the `ChannelManager`/`NetworkGraph` so the LDK handshake advertises the correct /// chain (the `networks` field of the `Init` message) instead of the historical Regtest default. static NETWORK_REGISTRY: RefCell> = diff --git a/bindings/wasm-sdk/src/ln_node.rs b/bindings/wasm-sdk/src/ln_node.rs index 08b5b52c..154f24bc 100644 --- a/bindings/wasm-sdk/src/ln_node.rs +++ b/bindings/wasm-sdk/src/ln_node.rs @@ -400,6 +400,7 @@ struct NodeRuntimeScope { lightning: Rc>>>, wallet_identity: RefCell>, identity_wallet: RefCell>>>, + enable_virtual_channels_v0: Rc>, network_transition: Cell, vss_owned: Rc>, } @@ -443,7 +444,7 @@ pub struct RlnWasmNode { configured_network: Rc>, wallet: RefCell>>>, relay_session_auth: RefCell>, - enable_virtual_channels_v0: RefCell, + enable_virtual_channels_v0: Rc>, reconnect_manager_running: Rc>, reconnect_manager_backoff_ms: Rc>, auto_drive_running: Rc>, @@ -494,7 +495,7 @@ impl RlnWasmNode { &runtime_key, crate::ldk_live_backend::rgb_network_to_bitcoin_network(selected), ); - if let Some(wallet) = self.wallet.borrow().as_ref() { + if let Some(wallet) = self.runtime_scope.identity_wallet.borrow().as_ref() { crate::ldk_live_backend::register_rgb_wallet_for_runtime( &runtime_key, Rc::clone(wallet), @@ -683,6 +684,12 @@ impl RlnWasmNode { lightning: Rc::new(RefCell::new(None)), wallet_identity: RefCell::new(None), identity_wallet: RefCell::new(None), + enable_virtual_channels_v0: Rc::new(RefCell::new( + load_virtual_channels_v0_flag( + &persistence_keys.virtual_channels_v0_storage_key, + ) + .unwrap_or_else(crate::sdk_default_enable_virtual_channels_v0), + )), network_transition: Cell::new(false), vss_owned: Rc::new(Cell::new(false)), }); @@ -715,12 +722,10 @@ impl RlnWasmNode { }) .unwrap_or_default(); let restored_network = runtime_scope.network.borrow().clone(); - let enable_virtual_channels_v0 = - load_virtual_channels_v0_flag(&persistence_keys.virtual_channels_v0_storage_key) - .unwrap_or_else(crate::sdk_default_enable_virtual_channels_v0); let node = Self { lightning: Rc::clone(&runtime_scope.lightning), configured_network: Rc::clone(&runtime_scope.network), + enable_virtual_channels_v0: Rc::clone(&runtime_scope.enable_virtual_channels_v0), runtime_scope, live_node_seed, auto_hooks_installed: Cell::new(false), @@ -741,7 +746,6 @@ impl RlnWasmNode { network: RefCell::new(restored_network), wallet: RefCell::new(None), relay_session_auth: RefCell::new(None), - enable_virtual_channels_v0: RefCell::new(enable_virtual_channels_v0), reconnect_manager_running: Rc::new(RefCell::new(false)), reconnect_manager_backoff_ms: Rc::new(RefCell::new(RECONNECT_MANAGER_INITIAL_DELAY_MS)), auto_drive_running: Rc::new(RefCell::new(false)), @@ -1316,19 +1320,30 @@ impl RlnWasmNode { if *self.reconnect_manager_running.borrow() { return self.reconnect_manager_status_value(); } - *self.reconnect_manager_running.borrow_mut() = true; - *self.reconnect_manager_backoff_ms.borrow_mut() = RECONNECT_MANAGER_INITIAL_DELAY_MS; + // Bare nodes are dormant until a Lightning operation selects their runtime. + // Preserve constructor-equivalent preparation before publishing a running loop. + self.prepare_lightning_runtime(false)?; + let runtime = self.lightning_runtime()?; + if !self.auto_hooks_installed.get() { + self.install_auto_peer_manager_hooks_inner(); + } + self.register_runtime_scope_for_local_pubkey(); + runtime.chain_sync.resume_if_running(); let proxy_url = self.proxy_url.clone(); let runtime_scope_key = self.persistence_keys.runtime_scope_key.clone(); let peer_session_store_key = self.persistence_keys.peer_sessions_storage_key.clone(); let relay_session_auth = self.relay_session_auth.borrow().clone(); let peers = Rc::clone(&self.peers); - let ldk_runtime = Rc::clone(&self.lightning_runtime()?.ldk_runtime); + let ldk_runtime = Rc::clone(&runtime.ldk_runtime); let running = Rc::clone(&self.reconnect_manager_running); let backoff_ms = Rc::clone(&self.reconnect_manager_backoff_ms); let bridge = self.bridge.clone(); + *self.reconnect_manager_running.borrow_mut() = true; + *self.reconnect_manager_backoff_ms.borrow_mut() = RECONNECT_MANAGER_INITIAL_DELAY_MS; + #[cfg(test)] + crate::ln_node::test_utils::record_startup_call("reconnect_task"); spawn_local(async move { let _ = reconnect_persisted_peers_once( &proxy_url, @@ -3877,16 +3892,21 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = driveRgbFundingWork)] pub async fn drive_rgb_funding_work(&self) -> Result<(), JsValue> { self.check_lightning_supported()?; - self.lightning_runtime()? - .ldk_runtime - .drive_rgb_funding_work_boxed() - .await + // With no live runtime, preserve the empty-backend no-op without loading saved work. + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return Ok(()); + }; + runtime.ldk_runtime.drive_rgb_funding_work_boxed().await } #[wasm_bindgen(js_name = processPendingRgbTransactions)] pub async fn process_pending_rgb_transactions(&self) -> Result<(), JsValue> { self.check_lightning_supported()?; - self.lightning_runtime()? + // With no live runtime, preserve the empty-backend no-op without loading saved work. + let Some(runtime) = self.lightning.borrow().as_ref().cloned() else { + return Ok(()); + }; + runtime .ldk_runtime .process_pending_rgb_transactions_boxed() .await diff --git a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs index 0beb2db4..f68ae367 100644 --- a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs +++ b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs @@ -796,3 +796,217 @@ async fn node_drop_releases_only_its_runtime_and_preserves_a_surviving_bridge() )); assert!(!second_manager.status().ready); } + +fn cold_shared_node(proxy: &str, runtime_id: &str) -> RlnWasmNode { + RlnWasmNode::new_with_runtime_id_opt(proxy.into(), Some(runtime_id.into()), None).unwrap() +} + +#[wasm_bindgen_test(async)] +async fn cold_shared_wallet_reaches_live_backend_for_either_owner_and_activation_order() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + let wallet = RlnWasmWallet::create(&crate::test_utils::test_wallet_data_json()) + .await + .unwrap(); + go_online_with_identity_fixture(&wallet, "regtest").await; + for owner_first in [false, true] { + for activate_owner in [false, true] { + let id = format!("wallet-{owner_first}-{activate_owner}"); + let first = cold_shared_node("ws://cold-shared-wallet.invalid", &id); + let second = cold_shared_node("ws://cold-shared-wallet.invalid", &id); + let (owner, other) = if owner_first { + (first, second) + } else { + (second, first) + }; + owner.attach_wallet(&wallet).unwrap(); + assert!(owner.lightning.borrow().is_none()); + assert!(other.lightning.borrow().is_none()); + let initial = if activate_owner { &owner } else { &other }; + initial.ensure_runtime_ready().unwrap(); + owner.ensure_runtime_ready().unwrap(); + other.ensure_runtime_ready().unwrap(); + let expected: serde_json::Value = + serde_json::from_str(&owner.node_pubkey_json().unwrap()).unwrap(); + let live = other + .lightning_runtime() + .unwrap() + .ldk_runtime + .live_node_pubkey() + .expect("the shared runtime must receive the already attached online wallet"); + assert_eq!(expected["pubkey"], live); + assert!(Rc::ptr_eq( + &owner.lightning_runtime().unwrap(), + &other.lightning_runtime().unwrap() + )); + } + } + // The shared scope must retain the validated wallet if its attaching handle is dropped cold. + let owner = cold_shared_node("ws://cold-shared-wallet.invalid", "dropped-owner"); + owner.attach_wallet(&wallet).unwrap(); + let survivor = cold_shared_node("ws://cold-shared-wallet.invalid", "dropped-owner"); + let expected = owner.node_pubkey_json().unwrap(); + drop(owner); + survivor.ensure_runtime_ready().unwrap(); + let live = survivor + .lightning_runtime() + .unwrap() + .ldk_runtime + .live_node_pubkey() + .unwrap(); + let expected: serde_json::Value = serde_json::from_str(&expected).unwrap(); + assert_eq!(expected["pubkey"], live); +} + +#[wasm_bindgen_test] +fn cold_shared_virtual_policy_survives_both_activation_orders_and_updates() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + for enabled in [false, true] { + for activate_setter in [false, true] { + for setter_first in [false, true] { + let id = format!("policy-{enabled}-{activate_setter}-{setter_first}"); + let first = cold_shared_node("ws://cold-shared-policy.invalid", &id); + let second = cold_shared_node("ws://cold-shared-policy.invalid", &id); + let (setter, other) = if setter_first { + (first, second) + } else { + (second, first) + }; + setter.set_enable_virtual_channels_v0(!enabled); + setter.set_enable_virtual_channels_v0(enabled); + assert_eq!( + crate::ldk_live_backend::registered_node_config_for_tests( + &setter.runtime_manager_key() + ), + (false, None) + ); + let initial = if activate_setter { &setter } else { &other }; + initial.ensure_runtime_ready().unwrap(); + assert_eq!( + crate::ldk_live_backend::registered_node_config_for_tests( + &setter.runtime_manager_key() + ), + (false, Some(enabled)) + ); + setter.ensure_runtime_ready().unwrap(); + other.ensure_runtime_ready().unwrap(); + for node in [&setter, &other] { + let value: serde_json::Value = + serde_json::from_str(&node.enable_virtual_channels_v0_json().unwrap()) + .unwrap(); + assert_eq!(value["enabled"], enabled); + } + other.set_enable_virtual_channels_v0(!enabled); + let value: serde_json::Value = + serde_json::from_str(&setter.enable_virtual_channels_v0_json().unwrap()) + .unwrap(); + assert_eq!(value["enabled"], !enabled); + assert_eq!( + crate::ldk_live_backend::registered_node_config_for_tests( + &setter.runtime_manager_key() + ), + (false, Some(!enabled)) + ); + } + } + } + let setter = cold_shared_node("ws://cold-shared-policy.invalid", "dropped-setter"); + let survivor = cold_shared_node("ws://cold-shared-policy.invalid", "dropped-setter"); + setter.set_enable_virtual_channels_v0(true); + drop(setter); + survivor.ensure_runtime_ready().unwrap(); + assert_eq!( + crate::ldk_live_backend::registered_node_config_for_tests(&survivor.runtime_manager_key()), + (false, Some(true)) + ); +} + +fn with_blocked_storage_read(key: &str, action: impl FnOnce() -> T) -> T { + let storage = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Storage")).unwrap(); + let prototype = js_sys::Reflect::get(&storage, &JsValue::from_str("prototype")).unwrap(); + let original = js_sys::Reflect::get(&prototype, &JsValue::from_str("getItem")).unwrap(); + let replacement = js_sys::Function::new_with_args("original, blocked", "return function(key) { if (key === blocked) throw new Error('injected reconnect read failure'); return original.call(this, key); };") + .call2(&JsValue::NULL, &original, &JsValue::from_str(key)).unwrap(); + js_sys::Reflect::set(&prototype, &JsValue::from_str("getItem"), &replacement).unwrap(); + let result = action(); + js_sys::Reflect::set(&prototype, &JsValue::from_str("getItem"), &original).unwrap(); + result +} + +#[wasm_bindgen_test(async)] +async fn cold_reconnect_start_prepares_once_and_failed_preparation_is_retryable() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + let node = cold_shared_node("ws://cold-reconnect.invalid", "retry"); + let before = test_utils::startup_calls(); + let failure = with_blocked_storage_read(&node.persistence_keys.chain_sync_storage_key, || { + node.reconnect_manager_start_value() + }); + assert!(failure.is_err()); + assert!( + !*node.reconnect_manager_running.borrow(), + "failed preparation must not publish running state" + ); + assert!(node.lightning.borrow().is_none()); + assert_eq!(node.configured_network.borrow().as_str(), "unknown"); + assert_eq!( + test_utils::startup_calls().get("reconnect_task"), + before.get("reconnect_task") + ); + assert!(!node.auto_hooks_installed.get()); + + let started: serde_json::Value = + serde_json::from_str(&node.reconnect_manager_start_json().unwrap()).unwrap(); + assert_eq!(started["running"], true); + assert_eq!(node.configured_network.borrow().as_str(), "regtest"); + assert_eq!(node.bridge.connection_hooks_ready().unwrap(), (true, true)); + let calls = test_utils::startup_calls(); + assert_eq!( + calls.get("reconnect_task").copied().unwrap_or(0), + before.get("reconnect_task").copied().unwrap_or(0) + 1 + ); + node.reconnect_manager_start_value().unwrap(); + assert_eq!( + test_utils::startup_calls(), + calls, + "idempotent start must not spawn a second loop" + ); + node.reconnect_manager_stop_value().unwrap(); + sleep_ms(0).await; + assert!(!*node.reconnect_manager_running.borrow()); +} + +#[wasm_bindgen_test(async)] +async fn cold_funding_workers_preserve_empty_runtime_noop() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + let node = cold_shared_node("ws://cold-funding-workers.invalid", "empty"); + let before = test_utils::startup_calls(); + node.drive_rgb_funding_work().await.unwrap(); + node.process_pending_rgb_transactions().await.unwrap(); + assert!(node.lightning.borrow().is_none()); + assert_eq!(test_utils::startup_calls(), before); +} + +#[wasm_bindgen_test(async)] +async fn mainnet_shared_wallet_policy_and_reconnect_never_register_or_start() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let wallet = mainnet_wallet().await; + let node = cold_shared_node("ws://mainnet-shared-policy.invalid", "shared"); + let other = cold_shared_node("ws://mainnet-shared-policy.invalid", "shared"); + let before = test_utils::startup_calls(); + node.attach_wallet(&wallet).unwrap(); + node.set_enable_virtual_channels_v0(true); + for handle in [&node, &other] { + assert_mainnet_rejection(handle.reconnect_manager_start_value()); + assert_mainnet_rejection(handle.reconnect_manager_start_json()); + assert!(!*handle.reconnect_manager_running.borrow()); + assert_eq!( + crate::ldk_live_backend::registered_node_config_for_tests( + &handle.runtime_manager_key() + ), + (false, None) + ); + } + assert_eq!(test_utils::startup_calls(), before); +} From 34de5de518283265ec31a88e6b9ca89e04a0c97b Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 2 Oct 2026 16:02:31 +0530 Subject: [PATCH 10/14] Inspect durable mainnet state without changing hydration --- bindings/wasm-sdk/src/runtime_store.rs | 254 ++++++++++++++-- .../wasm-sdk/src/tests/runtime_store_tests.rs | 272 ++++++++++++++++-- 2 files changed, 467 insertions(+), 59 deletions(-) diff --git a/bindings/wasm-sdk/src/runtime_store.rs b/bindings/wasm-sdk/src/runtime_store.rs index 90108eb6..7bab4db5 100644 --- a/bindings/wasm-sdk/src/runtime_store.rs +++ b/bindings/wasm-sdk/src/runtime_store.rs @@ -40,8 +40,6 @@ pub(crate) fn browser_persistent_state_store() -> BrowserPersistentStateStore { } pub(crate) const RUNTIME_STATE_HYDRATE_PREFIXES: &[&str] = &[ - "rln:ldk-kv:", - "rln:wasm:ldk-sweeps:", crate::wasm_node_persistence::WASM_LDK_RUNTIME_STORAGE_PREFIX, "rln:wasm:swap-runtime:", "rln:wasm:media:", @@ -60,42 +58,169 @@ pub(crate) async fn preload_runtime_state_from_persistent_store() -> Result<(), hydrate_local_storage_from_indexed_db_prefixes(RUNTIME_STATE_HYDRATE_PREFIXES).await } +#[cfg(target_arch = "wasm32")] +#[derive(Default)] +struct DurableStateInventory { + revision: u64, + keys: Option, String>>, + reads_in_flight: usize, + mutations: std::collections::BTreeMap, +} + +#[cfg(target_arch = "wasm32")] +struct InventoryRead { + revision: u64, +} + +#[cfg(target_arch = "wasm32")] +impl InventoryRead { + fn begin() -> Self { + DURABLE_STATE_INVENTORY.with(|inventory| { + let mut inventory = inventory.borrow_mut(); + inventory.reads_in_flight += 1; + Self { + revision: inventory.revision, + } + }) + } +} + +#[cfg(target_arch = "wasm32")] +impl Drop for InventoryRead { + fn drop(&mut self) { + DURABLE_STATE_INVENTORY.with(|inventory| { + let mut inventory = inventory.borrow_mut(); + inventory.reads_in_flight -= 1; + if inventory.reads_in_flight == 0 { + inventory.mutations.clear(); + } + }); + } +} + +#[cfg(target_arch = "wasm32")] +struct IndexedDbSnapshot { + entries: Vec, + keys: Result, String>, +} + #[cfg(target_arch = "wasm32")] thread_local! { static RUNTIME_STATE_PRELOADED: std::cell::RefCell = const { std::cell::RefCell::new(false) }; + static DURABLE_STATE_INVENTORY: std::cell::RefCell = const { + std::cell::RefCell::new(DurableStateInventory { + revision: 0, + keys: None, + reads_in_flight: 0, + mutations: std::collections::BTreeMap::new(), + }) + }; } #[cfg(target_arch = "wasm32")] async fn hydrate_local_storage_from_indexed_db_prefixes(prefixes: &[&str]) -> Result<(), JsValue> { let already = RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow()); - if already { + let complete = + DURABLE_STATE_INVENTORY.with(|inventory| matches!(inventory.borrow().keys, Some(Ok(_)))); + if already && complete { return Ok(()); } - let entries = indexed_db_list_entries().await?; - for entry in entries { - let pair = Array::from(&entry); - if pair.length() != 2 { - continue; + let read = InventoryRead::begin(); + let snapshot = match indexed_db_list_entries().await { + Ok(snapshot) => snapshot, + Err(error) => { + DURABLE_STATE_INVENTORY.with(|inventory| { + inventory.borrow_mut().keys = Some(Err("IndexedDB listing failed".into())); + }); + // An inventory retry must not change the old one-shot hydration contract. + return if already { Ok(()) } else { Err(error) }; } - let key = pair.get(0).as_string(); - let value = pair.get(1).as_string(); - let (Some(key), Some(value)) = (key, value) else { - continue; - }; - if prefixes.iter().any(|prefix| key.starts_with(prefix)) { - local_storage_set_item(&key, &value)?; + }; + finish_preload( + snapshot, + prefixes, + already, + read.revision, + local_storage_set_item, + ); + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +fn finish_preload( + snapshot: IndexedDbSnapshot, + prefixes: &[&str], + already: bool, + revision: u64, + mut write: impl FnMut(&str, &str) -> Result<(), JsValue>, +) { + if !already { + for entry in snapshot.entries { + if !Array::is_array(&entry) { + continue; + } + let pair = Array::from(&entry); + if pair.length() != 2 { + continue; + } + let key = pair.get(0).as_string(); + let value = pair.get(1).as_string(); + let (Some(key), Some(value)) = (key, value) else { + continue; + }; + if prefixes.iter().any(|prefix| key.starts_with(prefix)) { + // Preserve best-effort hydration on every network. Mainnet inspects the + // durable key inventory directly, even when this copy fails or is skipped. + let _ = write(&key, &value); + } } } + DURABLE_STATE_INVENTORY.with(|inventory| { + let mut inventory = inventory.borrow_mut(); + inventory.keys = Some(snapshot.keys.map(|mut keys| { + // A listing is an atomic readonly transaction, but successful writes can + // finish while its promise is pending. Overlay the last committed operation + // for each key since this read began; parallel reads retain the journal. + for (key, (changed_at, present)) in &inventory.mutations { + if *changed_at > revision { + if *present { + keys.insert(key.clone()); + } else { + keys.remove(key); + } + } + } + keys + })); + }); + RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow_mut() = true); +} - RUNTIME_STATE_PRELOADED.with(|loaded| { - *loaded.borrow_mut() = true; +#[cfg(target_arch = "wasm32")] +fn record_durable_mutation(key: &str, present: bool) { + DURABLE_STATE_INVENTORY.with(|inventory| { + let mut inventory = inventory.borrow_mut(); + inventory.revision = inventory.revision.wrapping_add(1); + if inventory.reads_in_flight != 0 { + let revision = inventory.revision; + inventory + .mutations + .insert(key.to_owned(), (revision, present)); + } + if let Some(Ok(keys)) = inventory.keys.as_mut() { + if present { + keys.insert(key.to_owned()); + } else { + keys.remove(key); + } + } }); - Ok(()) } /// Conservative, read-only preflight. Synchronous node constructors can inspect durable -/// state only after the caller has completed the existing asynchronous hydration step. +/// state only after the caller has completed the existing asynchronous preload. The +/// inventory is session-local; it does not discover writes made later by another tab. pub(crate) fn check_mainnet_runtime_state( keys: &crate::wasm_node_persistence::RuntimeScopeKeys, ) -> Result<(), JsValue> { @@ -125,14 +250,34 @@ pub(crate) fn check_mainnet_runtime_state( format!("rln:wasm:ldk-sweeps:{runtime}"), format!("rln:ldk-kv:{runtime}"), ]; + let is_protected = |key: &str| { + protected.iter().any(|prefix| { + key == prefix.as_str() + || key + .strip_prefix(prefix.as_str()) + .is_some_and(|suffix| suffix.starts_with(':')) + }) + }; + let durable_protected = DURABLE_STATE_INVENTORY.with(|inventory| { + let inventory = inventory.borrow(); + match inventory.keys.as_ref() { + Some(Ok(keys)) => Ok(keys.iter().any(|key| is_protected(key))), + Some(Err(reason)) => Err(JsValue::from_str(&format!( + "MainnetLightningState: Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: incomplete durable browser state inventory ({reason})" + ))), + None => Err(JsValue::from_str( + "Mainnet node initialization requires await sdk.preloadPersistentRuntimeState() before construction or wallet attachment", + )), + } + })?; + if durable_protected { + return Err(JsValue::from_str( + "MainnetLightningState: Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: protected browser runtime state is present", + )); + } for index in 0..storage.length()? { if let Some(key) = storage.key(index)? { - if protected.iter().any(|prefix| { - key == *prefix - || key - .strip_prefix(prefix.as_str()) - .is_some_and(|suffix| suffix.starts_with(':')) - }) { + if is_protected(&key) { return Err(JsValue::from_str( "MainnetLightningState: Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: protected browser runtime state is present", )); @@ -276,12 +421,14 @@ export function __rln_runtime_idb_entries() { tx.oncomplete = () => { const keys = keysReq.result || []; const vals = entriesReq.result || []; + const complete = Array.isArray(keysReq.result) && Array.isArray(entriesReq.result) + && keys.length === vals.length; const out = []; for (let i = 0; i < keys.length; i += 1) { out.push([String(keys[i]), typeof vals[i] === "string" ? vals[i] : ""]); } db.close(); - resolve(out); + resolve({ entries: out, keys, complete }); }; tx.onerror = () => { db.close(); reject(tx.error || new Error("indexedDB tx failed")); }; tx.onabort = () => { db.close(); reject(tx.error || new Error("indexedDB tx aborted")); }; @@ -320,24 +467,75 @@ extern "C" { async fn indexed_db_set_item(key: &str, value: &str) -> Result<(), JsValue> { let promise = __rln_runtime_idb_set(key, value); let _ = JsFuture::from(promise).await?; + record_durable_mutation(key, true); Ok(()) } #[cfg(target_arch = "wasm32")] -async fn indexed_db_list_entries() -> Result, JsValue> { +async fn indexed_db_list_entries() -> Result { let promise = __rln_runtime_idb_entries(); let value = JsFuture::from(promise).await?; - Ok(Array::from(&value).to_vec()) + Ok(parse_indexed_db_snapshot(&value)) +} + +#[cfg(target_arch = "wasm32")] +fn parse_indexed_db_snapshot(value: &JsValue) -> IndexedDbSnapshot { + let entries = + js_sys::Reflect::get(value, &JsValue::from_str("entries")).unwrap_or(JsValue::UNDEFINED); + let entries_valid = Array::is_array(&entries); + let entries = if entries_valid { + Array::from(&entries).to_vec() + } else { + Vec::new() + }; + let raw_keys = + js_sys::Reflect::get(value, &JsValue::from_str("keys")).unwrap_or(JsValue::UNDEFINED); + let complete = js_sys::Reflect::get(value, &JsValue::from_str("complete")) + .ok() + .and_then(|value| value.as_bool()) + == Some(true); + let keys = (|| { + if !complete || !entries_valid || !Array::is_array(&raw_keys) { + return Err("malformed or incomplete IndexedDB listing".into()); + } + let raw_keys = Array::from(&raw_keys); + if raw_keys.length() as usize != entries.len() { + return Err("IndexedDB key and value counts differ".into()); + } + let mut keys = std::collections::BTreeSet::new(); + for (index, entry) in entries.iter().enumerate() { + let key = raw_keys + .get(index as u32) + .as_string() + .ok_or("non-string IndexedDB key")?; + if !Array::is_array(entry) { + return Err("malformed IndexedDB entry".into()); + } + let pair = Array::from(entry); + if pair.length() != 2 + || pair.get(0).as_string().as_ref() != Some(&key) + || pair.get(1).as_string().is_none() + || !keys.insert(key) + { + return Err("malformed or inconsistent IndexedDB entry".into()); + } + } + Ok(keys) + })(); + IndexedDbSnapshot { entries, keys } } #[cfg(target_arch = "wasm32")] async fn indexed_db_delete_item(key: &str) -> Result<(), JsValue> { let promise = __rln_runtime_idb_delete(key); let _ = JsFuture::from(promise).await?; + record_durable_mutation(key, false); Ok(()) } #[cfg(all(test, target_arch = "wasm32"))] pub(crate) fn reset_preload_readiness_for_tests() { RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow_mut() = false); + DURABLE_STATE_INVENTORY + .with(|inventory| *inventory.borrow_mut() = DurableStateInventory::default()); } diff --git a/bindings/wasm-sdk/src/tests/runtime_store_tests.rs b/bindings/wasm-sdk/src/tests/runtime_store_tests.rs index 04735e80..bf407ff3 100644 --- a/bindings/wasm-sdk/src/tests/runtime_store_tests.rs +++ b/bindings/wasm-sdk/src/tests/runtime_store_tests.rs @@ -16,6 +16,7 @@ fn hydrate_prefixes_cover_runtime_state_domains() { crate::wasm_node_persistence::WASM_VIRTUAL_CHANNELS_V0_STORAGE_PREFIX, crate::wasm_node_persistence::WASM_PEER_SESSIONS_STORAGE_PREFIX, ]; + assert_eq!(RUNTIME_STATE_HYDRATE_PREFIXES, must_include); for prefix in must_include { assert!( RUNTIME_STATE_HYDRATE_PREFIXES.contains(&prefix), @@ -25,35 +26,244 @@ fn hydrate_prefixes_cover_runtime_state_domains() { } #[cfg(target_arch = "wasm32")] -#[wasm_bindgen_test::wasm_bindgen_test(async)] -async fn mainnet_preflight_hydrates_indexeddb_only_kv_and_retains_legacy_state() { - use super::*; - let scope = "ws://mainnet-idb-recovery.invalid#runtime:identity"; - let keys = crate::wasm_node_persistence::RuntimeScopeKeys::from_runtime_scope_key(scope.into()); - let key = format!( - "rln:ldk-kv:{}:monitors:monitor_updates:pending", - keys.ldk_manager_registry_key - ); - let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); - indexed_db_set_item(&key, "preserve-remote-format-bytes") - .await - .unwrap(); - storage.remove_item(&key).unwrap(); - reset_preload_readiness_for_tests(); - preload_runtime_state_from_persistent_store().await.unwrap(); - assert_eq!( - storage.get_item(&key).unwrap().as_deref(), - Some("preserve-remote-format-bytes") - ); - assert!(check_mainnet_runtime_state(&keys) - .unwrap_err() - .as_string() - .unwrap() - .starts_with("MainnetLightningState:")); - assert_eq!( - storage.get_item(&key).unwrap().as_deref(), - Some("preserve-remote-format-bytes") - ); - indexed_db_delete_item(&key).await.unwrap(); - storage.remove_item(&key).unwrap(); +mod browser { + use super::super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + fn scope(name: &str) -> crate::wasm_node_persistence::RuntimeScopeKeys { + crate::wasm_node_persistence::RuntimeScopeKeys::from_runtime_scope_key(format!( + "ws://runtime-inventory-{name}.invalid#runtime:identity" + )) + } + + fn snapshot_value(keys: &[&str]) -> JsValue { + let raw_keys = Array::new(); + let entries = Array::new(); + for key in keys { + raw_keys.push(&JsValue::from_str(key)); + let pair = Array::new(); + pair.push(&JsValue::from_str(key)); + pair.push(&JsValue::from_str("preserved bytes")); + entries.push(&pair); + } + let value = js_sys::Object::new(); + js_sys::Reflect::set(&value, &"keys".into(), &raw_keys).unwrap(); + js_sys::Reflect::set(&value, &"entries".into(), &entries).unwrap(); + js_sys::Reflect::set(&value, &"complete".into(), &JsValue::TRUE).unwrap(); + value.into() + } + + fn mainnet_error(keys: &crate::wasm_node_persistence::RuntimeScopeKeys) -> String { + check_mainnet_runtime_state(keys) + .unwrap_err() + .as_string() + .unwrap() + } + + #[wasm_bindgen_test(async)] + async fn mainnet_preflight_detects_durable_kv_and_sweeps_without_hydrating_them() { + let keys = scope("protected"); + let protected = [ + format!( + "rln:ldk-kv:{}:monitors:monitor_updates:pending", + keys.ldk_manager_registry_key + ), + format!("rln:wasm:ldk-sweeps:{}", keys.ldk_manager_registry_key), + ]; + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + for key in &protected { + indexed_db_set_item(key, "preserve-remote-format-bytes") + .await + .unwrap(); + storage.remove_item(key).unwrap(); + } + reset_preload_readiness_for_tests(); + preload_runtime_state_from_persistent_store().await.unwrap(); + for key in &protected { + assert_eq!(storage.get_item(key).unwrap(), None); + } + assert!(mainnet_error(&keys).starts_with("MainnetLightningState:")); + let durable = indexed_db_list_entries().await.unwrap(); + for key in &protected { + assert!(durable.entries.iter().any(|entry| { + let pair = Array::from(entry); + pair.get(0).as_string().as_ref() == Some(key) + && pair.get(1).as_string().as_deref() == Some("preserve-remote-format-bytes") + })); + indexed_db_delete_item(key).await.unwrap(); + } + check_mainnet_runtime_state(&keys).unwrap(); + } + + #[wasm_bindgen_test] + fn mainnet_preflight_uses_inventory_when_best_effort_copy_fails() { + let keys = scope("copy-error"); + let protected = keys.chain_sync_storage_key.clone(); + let allowed = "rln:wasm:media:mainnet-copy-error"; + let value = snapshot_value(&[allowed, &protected]); + reset_preload_readiness_for_tests(); + let mut attempted = Vec::new(); + finish_preload( + parse_indexed_db_snapshot(&value), + RUNTIME_STATE_HYDRATE_PREFIXES, + false, + 0, + |key, _| { + attempted.push(key.to_owned()); + Err(JsValue::from_str("simulated quota failure")) + }, + ); + assert_eq!(attempted, [allowed.to_owned(), protected]); + assert!(RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow())); + assert!(mainnet_error(&keys).contains("protected browser runtime state")); + + // Copy failures for unrelated data must not become a new Mainnet refusal. + reset_preload_readiness_for_tests(); + finish_preload( + parse_indexed_db_snapshot(&snapshot_value(&[allowed])), + RUNTIME_STATE_HYDRATE_PREFIXES, + false, + 0, + |_, _| Err(JsValue::from_str("simulated quota failure")), + ); + check_mainnet_runtime_state(&keys).unwrap(); + } + + #[wasm_bindgen_test] + fn mainnet_preflight_rejects_malformed_or_incomplete_inventory() { + let keys = scope("invalid-inventory"); + let missing = js_sys::Object::new().into(); + let incomplete = snapshot_value(&[]); + js_sys::Reflect::set(&incomplete, &"complete".into(), &JsValue::FALSE).unwrap(); + let mismatched_count = snapshot_value(&["valid-key"]); + js_sys::Reflect::set(&mismatched_count, &"entries".into(), &Array::new()).unwrap(); + let non_string_key = snapshot_value(&["valid-key"]); + let raw_keys = Array::new(); + raw_keys.push(&JsValue::from_f64(7.0)); + js_sys::Reflect::set(&non_string_key, &"keys".into(), &raw_keys).unwrap(); + let malformed_entry = snapshot_value(&["valid-key"]); + let entries = Array::new(); + entries.push(&JsValue::NULL); + js_sys::Reflect::set(&malformed_entry, &"entries".into(), &entries).unwrap(); + let inconsistent_key = snapshot_value(&["valid-key"]); + let raw_keys = Array::new(); + raw_keys.push(&JsValue::from_str("different-key")); + js_sys::Reflect::set(&inconsistent_key, &"keys".into(), &raw_keys).unwrap(); + for value in [ + missing, + incomplete, + mismatched_count, + non_string_key, + malformed_entry, + inconsistent_key, + ] { + reset_preload_readiness_for_tests(); + finish_preload( + parse_indexed_db_snapshot(&value), + RUNTIME_STATE_HYDRATE_PREFIXES, + false, + 0, + |_, _| Ok(()), + ); + assert!(RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow())); + assert!(mainnet_error(&keys).contains("incomplete durable browser state inventory")); + } + reset_preload_readiness_for_tests(); + assert!(mainnet_error(&keys).contains("preloadPersistentRuntimeState")); + } + + #[wasm_bindgen_test(async)] + async fn mainnet_inventory_tracks_successful_durable_writes_and_deletes() { + let keys = scope("mutations"); + let key = format!("rln:ldk-kv:{}:manager", keys.ldk_manager_registry_key); + reset_preload_readiness_for_tests(); + preload_runtime_state_from_persistent_store().await.unwrap(); + check_mainnet_runtime_state(&keys).unwrap(); + indexed_db_set_durable(key.clone(), "new durable state".into()) + .await + .unwrap(); + assert!(mainnet_error(&keys).contains("protected browser runtime state")); + indexed_db_delete_item(&key).await.unwrap(); + check_mainnet_runtime_state(&keys).unwrap(); + } + + #[wasm_bindgen_test(async)] + async fn mainnet_inventory_merges_mutations_during_overlapping_preloads() { + let keys = scope("concurrent-write"); + let key = format!("rln:ldk-kv:{}:manager", keys.ldk_manager_registry_key); + reset_preload_readiness_for_tests(); + let first = InventoryRead::begin(); + let first_snapshot = indexed_db_list_entries().await.unwrap(); + indexed_db_set_durable(key.clone(), "first committed write".into()) + .await + .unwrap(); + let second = InventoryRead::begin(); + let second_snapshot = indexed_db_list_entries().await.unwrap(); + indexed_db_delete_item(&key).await.unwrap(); + finish_preload( + second_snapshot, + RUNTIME_STATE_HYDRATE_PREFIXES, + false, + second.revision, + |_, _| Ok(()), + ); + drop(second); + // The write-then-delete overlay removes the key from the second snapshot. + check_mainnet_runtime_state(&keys).unwrap(); + assert_eq!( + DURABLE_STATE_INVENTORY.with(|inventory| inventory.borrow().reads_in_flight), + 1 + ); + + indexed_db_set_durable(key.clone(), "write after deletion".into()) + .await + .unwrap(); + finish_preload( + first_snapshot, + RUNTIME_STATE_HYDRATE_PREFIXES, + true, + first.revision, + |_, _| panic!("an inventory-only refresh must not hydrate again"), + ); + drop(first); + // The delete-then-write overlay preserves the latest committed protected key, + // even though the first snapshot predates both writes. + assert!(mainnet_error(&keys).contains("protected browser runtime state")); + DURABLE_STATE_INVENTORY.with(|inventory| { + let inventory = inventory.borrow(); + assert_eq!(inventory.reads_in_flight, 0); + assert!( + inventory.mutations.is_empty(), + "completed reads must not retain tombstones" + ); + }); + indexed_db_delete_item(&key).await.unwrap(); + check_mainnet_runtime_state(&keys).unwrap(); + } + + #[wasm_bindgen_test] + fn mainnet_preflight_checks_current_local_storage_and_scope_boundaries() { + let keys = scope("local-state"); + let protected = format!("rln:ldk-kv:{}", keys.ldk_manager_registry_key); + let neighbor = format!("{protected}-other-scope:manager"); + reset_preload_readiness_for_tests(); + finish_preload( + parse_indexed_db_snapshot(&snapshot_value(&[&neighbor])), + RUNTIME_STATE_HYDRATE_PREFIXES, + false, + 0, + |_, _| Ok(()), + ); + check_mainnet_runtime_state(&keys).unwrap(); + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + storage + .set_item(&protected, "new local-only state") + .unwrap(); + assert!(mainnet_error(&keys).contains("protected browser runtime state")); + assert_eq!( + storage.get_item(&protected).unwrap().as_deref(), + Some("new local-only state") + ); + storage.remove_item(&protected).unwrap(); + } } From 2d91b76c9514a930a4bef2b911686e1b37e7604b Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 2 Oct 2026 16:02:31 +0530 Subject: [PATCH 11/14] Run browser regressions in CI and document startup --- .github/workflows/wasm-artifacts.yaml | 6 ++++++ bindings/wasm-sdk/README.md | 16 +++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wasm-artifacts.yaml b/.github/workflows/wasm-artifacts.yaml index 250105a8..0b716114 100644 --- a/.github/workflows/wasm-artifacts.yaml +++ b/.github/workflows/wasm-artifacts.yaml @@ -28,6 +28,12 @@ jobs: run: rustup target add wasm32-unknown-unknown - name: Install wasm-pack run: cargo install wasm-pack --locked + - name: Test browser SDK + working-directory: bindings/wasm-sdk + env: + WASM_BINDGEN_TEST_TIMEOUT: 120 + run: | + wasm-pack test --headless --chrome --chromedriver "$(command -v chromedriver)" --locked - name: Build wasm-sdk package (web target) run: | cd bindings/wasm-sdk diff --git a/bindings/wasm-sdk/README.md b/bindings/wasm-sdk/README.md index f04eb0ab..ee2f8c49 100644 --- a/bindings/wasm-sdk/README.md +++ b/bindings/wasm-sdk/README.md @@ -29,7 +29,8 @@ share the existing runtime without reseeding it. Before constructing a Mainnet node or attaching its wallet, call `await sdk.preloadPersistentRuntimeState()` (SDK `init`/`unlock` already preload). -Construction and adoption check the scope's hydrated local browser storage. Any +Construction and adoption check the scope's localStorage and durable key inventory +from the preload. An incomplete inventory refuses Mainnet initialization. Any protected Lightning snapshot, monitor, queue, sweep, RGB Lightning KV or peer state refuses with `MainnetLightningState`, without decoding, resuming or deleting it: @@ -38,8 +39,10 @@ MainnetLightningState: Existing Lightning state requires recovery review before ``` This conservative check also refuses historical snapshots from a previously -on-chain-only node. It checks the current browser's hydrated IndexedDB/localStorage; -it cannot inspect remote-only `-ldk` VSS recovery state. Neither the +on-chain-only node. It checks the current browser's IndexedDB inventory and localStorage. +The inventory includes writes made through this SDK after preload, but does not +discover later writes from another tab. It cannot inspect remote-only +`-ldk` VSS recovery state. Neither the synchronous constructor nor SDK `init`/`unlock` accepts that store's credentials. Before reusing an identity that previously used remote Lightning storage, an operator must review the exact VSS server, LDK store and signing identity configured @@ -229,9 +232,12 @@ selection and validation in `new_with_runtime_id_opt` / `attach_wallet_shared` i WASM checks run in `.github/workflows/test.yaml`: the `feature-matrix` job's `wasm-without-vls` mode runs `cargo check --target wasm32-unknown-unknown` against `bindings/wasm-sdk/Cargo.toml`. The package `pkg/` artifact is built separately by -`.github/workflows/wasm-artifacts.yaml` via `wasm-pack build`. +`.github/workflows/wasm-artifacts.yaml` via `wasm-pack build`. That job also runs +the browser unit suite, including startup and storage regressions, in headless +Chrome with the existing `wasm-bindgen-test` harness. The default suite does not +require funded wallets or live Lightning services. -Run the browser unit tests locally (not run in CI): +Run the complete browser unit suite locally: ```sh WASM_BINDGEN_TEST_TIMEOUT=300 WASM_TEST_BROWSER=chrome ./bindings/wasm-sdk/scripts/run-browser-tests.sh From 18635daf6b8051bf11dc6362b1de93d98c86de7a Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 2 Oct 2026 16:39:34 +0530 Subject: [PATCH 12/14] Preserve shared network after failed browser construction --- bindings/wasm-sdk/src/ln_node.rs | 34 +++++- .../src/tests/mainnet_lightning_tests.rs | 104 ++++++++++++++++++ 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/bindings/wasm-sdk/src/ln_node.rs b/bindings/wasm-sdk/src/ln_node.rs index 154f24bc..17fbd426 100644 --- a/bindings/wasm-sdk/src/ln_node.rs +++ b/bindings/wasm-sdk/src/ln_node.rs @@ -472,12 +472,23 @@ impl RlnWasmNode { "runtime network selection is in progress", )); } + let network = self.configured_network.borrow().clone(); + self.prepare_lightning_runtime_for_network(start, &network) + } + + // A promoting constructor holds the scope transition while staging its requested network. + // Publish that selection only after all fallible runtime preparation succeeds. + fn prepare_lightning_runtime_for_network( + &self, + start: bool, + network: &str, + ) -> Result<(), JsValue> { + crate::check_lightning_supported(network)?; if self.lightning.borrow().is_none() { - let network = self.configured_network.borrow().clone(); let resolved = if network == "unknown" { "regtest" } else { - &network + network }; let selected = crate::WasmRlnNetwork::parse(resolved)?.as_rgb(); let runtime_key = self.runtime_manager_key(); @@ -673,9 +684,6 @@ impl RlnWasmNode { "runtime scope already uses a different Bitcoin network", )); } - if previous == "unknown" && network_label != "unknown" { - *scope.network.borrow_mut() = network_label.to_string(); - } return Ok(scope); } let scope = Rc::new(NodeRuntimeScope { @@ -699,6 +707,13 @@ impl RlnWasmNode { ); Ok(scope) })?; + let network_selection = + if runtime_scope.network.borrow().as_str() == "unknown" && network_label != "unknown" { + runtime_scope.network_transition.set(true); + Some(NodeNetworkTransition(Rc::clone(&runtime_scope))) + } else { + None + }; let runtime_event_snapshot = load_runtime_event_log_snapshot(&persistence_keys.runtime_events_storage_key); let runtime_events = runtime_event_snapshot @@ -756,10 +771,17 @@ impl RlnWasmNode { crate::check_lightning_supported(&configured_network.borrow()) })); if network.is_some() && network_label != "mainnet" { - node.prepare_lightning_runtime(false)?; + if network_selection.is_some() { + node.prepare_lightning_runtime_for_network(false, network_label)?; + } else { + node.prepare_lightning_runtime(false)?; + } node.install_auto_peer_manager_hooks_inner(); node.register_runtime_scope_for_local_pubkey(); node.lightning_runtime()?.chain_sync.resume_if_running(); + } else if network_selection.is_some() { + *node.configured_network.borrow_mut() = network_label.to_string(); + *node.network.borrow_mut() = network_label.to_string(); } Ok(node) } diff --git a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs index f68ae367..e807b3ad 100644 --- a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs +++ b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs @@ -1010,3 +1010,107 @@ async fn mainnet_shared_wallet_policy_and_reconnect_never_register_or_start() { } assert_eq!(test_utils::startup_calls(), before); } + +#[wasm_bindgen_test(async)] +async fn failed_explicit_sibling_constructor_preserves_cold_network_selection() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let node = cold_shared_node("ws://constructor-selection.invalid", "failed-sibling"); + let failure = with_blocked_storage_read(&node.persistence_keys.chain_sync_storage_key, || { + RlnWasmNode::new_with_node_runtime_id( + node.proxy_url.clone(), + "failed-sibling".into(), + "regtest".into(), + ) + }); + assert!(failure.is_err()); + assert_eq!(node.configured_network.borrow().as_str(), "unknown"); + assert!(!node.runtime_scope.network_transition.get()); + assert!(node.lightning.borrow().is_none()); + assert!(!has_peer_manager_hooks()); + assert_eq!( + crate::ldk_live_backend::registered_node_config_for_tests(&node.runtime_manager_key()), + (false, None) + ); + let wallet = mainnet_wallet().await; + let before = test_utils::startup_calls(); + node.attach_wallet(&wallet).unwrap(); + assert_eq!(node.configured_network.borrow().as_str(), "mainnet"); + assert!(node.lightning.borrow().is_none()); + assert_eq!(test_utils::startup_calls(), before); +} + +#[wasm_bindgen_test(async)] +async fn explicit_sibling_constructor_blocks_reentrant_selection_and_can_retry() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::runtime_store::preload_runtime_state_from_persistent_store() + .await + .unwrap(); + let node = Rc::new(cold_shared_node( + "ws://constructor-selection.invalid", + "reentrant-sibling", + )); + let observed = Rc::new(RefCell::new(None)); + let observed_callback = Rc::clone(&observed); + let node_callback = Rc::clone(&node); + let callback = wasm_bindgen::closure::Closure::::new(move || { + let network = node_callback.configured_network.borrow().clone(); + let result = RlnWasmNode::new_with_node_runtime_id( + node_callback.proxy_url.clone(), + "reentrant-sibling".into(), + "mainnet".into(), + ); + observed_callback.replace(Some(( + network, + result.err().and_then(|err| err.as_string()), + ))); + }); + let storage = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Storage")).unwrap(); + let prototype = js_sys::Reflect::get(&storage, &JsValue::from_str("prototype")).unwrap(); + let original = js_sys::Reflect::get(&prototype, &JsValue::from_str("getItem")).unwrap(); + let replacement = js_sys::Function::new_with_args("original, blocked, callback", "return function(key) { if (key === blocked) { callback(); throw new Error('injected constructor read failure'); } return original.call(this, key); };") + .call3(&JsValue::NULL, &original, &JsValue::from_str(&node.persistence_keys.chain_sync_storage_key), callback.as_ref()).unwrap(); + js_sys::Reflect::set(&prototype, &JsValue::from_str("getItem"), &replacement).unwrap(); + let failure = RlnWasmNode::new_with_node_runtime_id( + node.proxy_url.clone(), + "reentrant-sibling".into(), + "signet".into(), + ); + js_sys::Reflect::set(&prototype, &JsValue::from_str("getItem"), &original).unwrap(); + assert!(failure.is_err()); + assert_eq!( + observed.borrow().as_ref(), + Some(&( + "unknown".to_owned(), + Some("runtime network selection is in progress".to_owned()) + )) + ); + assert_eq!(node.configured_network.borrow().as_str(), "unknown"); + assert!(!node.runtime_scope.network_transition.get()); + assert!(node.lightning.borrow().is_none()); + + let sibling = RlnWasmNode::new_with_node_runtime_id( + node.proxy_url.clone(), + "reentrant-sibling".into(), + "signet".into(), + ) + .unwrap(); + assert_eq!(node.configured_network.borrow().as_str(), "signet"); + assert_eq!( + sibling + .lightning_runtime() + .unwrap() + .chain_sync + .status() + .network, + "signet" + ); + assert!(!node.runtime_scope.network_transition.get()); + assert!(node.lightning.borrow().is_some()); + assert_eq!( + sibling.bridge.connection_hooks_ready().unwrap(), + (true, true) + ); +} From ad3f1949ddf2e4b49100343015ca4715aec3e424 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 6 Oct 2026 13:07:48 +0530 Subject: [PATCH 13/14] Preserve historical mainnet state during wallet startup --- README.md | 42 ++-- bindings/c-ffi/README.md | 8 +- bindings/c-ffi/src/utils.rs | 12 - bindings/rgb_lightning_node.udl | 1 - openapi.yaml | 51 ++-- src/error.rs | 24 +- src/ldk.rs | 49 ++-- src/mainnet_startup_tests.rs | 75 +++++- src/mainnet_state.rs | 401 ++------------------------------ src/mainnet_vss_tests.rs | 105 +++++++-- src/synced_kv_store.rs | 288 ++++++++++++++++++++++- src/uniffi_api/README.md | 14 +- src/uniffi_api/state.rs | 1 - src/uniffi_api/tests.rs | 13 -- src/uniffi_api/types.rs | 2 - 15 files changed, 519 insertions(+), 567 deletions(-) diff --git a/README.md b/README.md index 98d841c5..20159538 100644 --- a/README.md +++ b/README.md @@ -43,21 +43,21 @@ wallet indexer's current height on demand and returns an error if it cannot be r the indexer is on another network. Non-mainnet `networkinfo` continues to use LDK's height. Lightning and wallet/signing policies on supported non-mainnet networks are unchanged. -An existing mainnet wallet with persisted Lightning state returns HTTP 409 -`MainnetLightningState` during unlock, with a message beginning: - -> Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: - -This check is conservative: even empty channel-manager or sweeper snapshots from an -earlier on-chain-only installation require review. It also checks local pending -replication, peer history and the configured remote Lightning store. RLN preserves -these records and does not start LDK to inspect them. Do not delete records or clear -remote stores to bypass the check. An operator must review any channel, funding or -sweep obligations and arrange recovery with a release that can monitor them before -upgrading that wallet. Refusing unlock does not keep existing channels monitored. -Fresh mainnet wallets and wallets previously unlocked by this implementation can -unlock normally. RGB VSS restore and backup continue using the existing wallet store; -mainnet does not restore or replicate Lightning snapshots. +Mainnet startup permits records left by older releases, including empty Lightning snapshots +from wallets that only used on-chain methods. These records remain inactive: RLN does not +decode or recover them, replay their pending replication, or start LDK to inspect them. +Native persistence may update only the six existing common configuration mirrors in the +node KV store; historical Lightning records and their pending writes/deletes remain +untouched locally and in VSS. RGB wallet backup and restore keep their existing separate +store and ownership requirements. + +This release assumes the supported mainnet rollout has no unresolved historical Lightning +channel, funding, HTLC, claim, sweep or RGB Lightning obligations. Successful unlock does +not verify that assumption, discover other devices or remote histories, or monitor old +channels. A wallet with such obligations needs a separately reviewed recovery path before +using this release. Preserving records is not a migration or a guarantee of compatibility +with a future LDK version. Future mainnet Lightning enablement must address restoration, +channel monitoring, ownership and downgrade handling before accepting activity. Please be careful, this software is early alpha, we do not take any responsibility for loss of funds or any other issue you may encounter. @@ -569,7 +569,7 @@ the node behaves exactly as before. Two independent data streams are backed up: -- **Node KV state** — channel manager, channel monitors, payment info, swap data and RGB channel info. Every update is written to both the local SQLite database and the remote VSS server; channel-monitor updates are persisted remote-first, completing only once the VSS server has durably stored them. +- **Node KV state** — on supported non-mainnet networks, channel manager, channel monitors, payment info, swap data and RGB channel info. Every update is written to both the local SQLite database and the remote VSS server; channel-monitor updates are persisted remote-first, completing only once the VSS server has durably stored them. Mainnet replicates only the existing common wallet configuration mirrors; historical Lightning records and their pending replication remain inactive. - **RGB wallet data** — the wallet files managed by rgb-lib, backed up automatically after every state-changing wallet operation. @@ -614,18 +614,18 @@ read and restored by a node initialized with the same mnemonic. ### Recovery -On a fresh start `unlock` restores both replicated streams from VSS before +On a fresh start `unlock` restores the applicable streams from VSS before the node finishes coming up: - the **KV stream** (channel manager, monitors, payments, scorer, swap data, - RGB channel info) is restored when the local database has no - channel-manager state; + RGB channel info) is restored on supported non-mainnet networks when the local + database has no channel-manager state. Mainnet does not restore this stream; - the **RGB wallet directory** (assets, transfers, allocations) is restored when the local wallet directory for this mnemonic's fingerprint is absent. -Together these recover BTC balance, channels, and RGB assets without any extra calls — `unlock` is the only entry point, provided the node was initialized (`/init`) with the same mnemonic as the original device and started with the same `--vss-url` and `--network`. A different mnemonic maps to a different VSS store, so the node finds no backup and starts fresh without an error. Each VSS store is owned by a single running node instance, so a second node pointed at the same store refuses to start to avoid corrupting state. A graceful teardown (lock, shutdown, signal) releases the fence; after a crash or hard kill it is left behind and the operator must call `POST /vssclearfence` (or `SdkNode::vss_clear_fence`) once between `init` and `unlock` to take over the store. In internal-mnemonic mode this is authenticated by the wallet password; in external-signer mode (no mnemonic on the node) the password is ignored and the VSS identity is reconstructed from the persisted `key_source.json`, matching the identity the node acquires the fence under. +These recover the on-chain wallet, including BTC and RGB assets, and on supported non-mainnet networks also Lightning state, without any extra calls — `unlock` is the only entry point, provided the node was initialized (`/init`) with the same mnemonic as the original device and started with the same `--vss-url` and `--network`. A different mnemonic maps to a different VSS store, so the node finds no backup and starts fresh without an error. Each VSS store is owned by a single running node instance, so a second node pointed at the same store refuses to start to avoid corrupting state. A graceful teardown (lock, shutdown, signal) releases the fence; after a crash or hard kill it is left behind and the operator must call `POST /vssclearfence` (or `SdkNode::vss_clear_fence`) once between `init` and `unlock` to take over the store. In internal-mnemonic mode this is authenticated by the wallet password; in external-signer mode (no mnemonic on the node) the password is ignored and the VSS identity is reconstructed from the persisted `key_source.json`, matching the identity the node acquires the fence under. -Replication guarantees differ per stream. Channel-monitor writes are remote-first: each write completes only after the VSS server durably stores it, and transient server failures are retried with capped exponential backoff until the server recovers. All other KV writes land in the local database first and are replicated to VSS best-effort: a write that fails to reach the server is queued and retried on later successful writes. The number of pending best-effort writes is reported by `GET /vssbackupinfo` so monitoring can alert on persistent staleness. +Replication guarantees differ per stream. On supported non-mainnet networks, channel-monitor writes are remote-first: each write completes only after the VSS server durably stores it, and transient server failures are retried with capped exponential backoff until the server recovers. All other KV writes land in the local database first and are replicated to VSS best-effort: a write that fails to reach the server is queued and retried on later successful writes. The number of pending best-effort writes is reported by `GET /vssbackupinfo` so monitoring can alert on persistent staleness. On mainnet this counts only active common configuration retries; preserved historical Lightning intents are not loaded into the retry queue or included in this count. ### API endpoints (when VSS is enabled) - `POST /vssbackup` — trigger a manual RGB wallet backup diff --git a/bindings/c-ffi/README.md b/bindings/c-ffi/README.md index 600bd58f..6e32a8de 100644 --- a/bindings/c-ffi/README.md +++ b/bindings/c-ffi/README.md @@ -53,9 +53,11 @@ When the node is configured for mainnet, Lightning operations return a failed `CResultString` whose error is prefixed with `Rln(LightningUnsupportedOnMainnet):` and contains: "RLN on mainnet currently supports only on-chain methods. Lightning APIs are not supported." On-chain and shared administrative APIs retain their existing -requirements. Mainnet unlock does not start the Lightning runtime. Existing Lightning state -returns `Rln(MainnetLightningState):` and requires operator review, including empty -snapshots created by older releases. See the [native SDK availability documentation](../../src/uniffi_api/README.md#mainnet-api-availability). +requirements. Mainnet unlock does not start the Lightning runtime and allows historical +Lightning records to remain inactive without replaying or recovering them. This assumes +no unresolved historical mainnet Lightning obligations in the supported rollout; +successful unlock does not establish that fact. See the [native SDK availability +documentation](../../src/uniffi_api/README.md#mainnet-api-availability). ## Memory ownership diff --git a/bindings/c-ffi/src/utils.rs b/bindings/c-ffi/src/utils.rs index 9ad93dca..98c33722 100644 --- a/bindings/c-ffi/src/utils.rs +++ b/bindings/c-ffi/src/utils.rs @@ -126,7 +126,6 @@ fn rln_variant_tag(e: &RlnError) -> &'static str { RlnError::FailedVssInit(_) => "FailedVssInit", RlnError::Internal(_) => "Internal", RlnError::LightningUnsupportedOnMainnet(_) => "LightningUnsupportedOnMainnet", - RlnError::MainnetLightningState(_) => "MainnetLightningState", } } @@ -265,17 +264,6 @@ pub(crate) fn require_signer( mod tests { use super::*; - #[test] - fn mainnet_legacy_state_error_preserves_category_and_message() { - let _ = rgb_lightning_node::take_last_api_error_detail(); - let message = "Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: local manager snapshot"; - let err = Error::from(RlnError::MainnetLightningState(message.into())); - assert_eq!( - format_error_for_ffi(&err), - format!("Rln(MainnetLightningState): {message}") - ); - } - #[test] fn mainnet_lightning_error_preserves_category_and_message() { let _ = rgb_lightning_node::take_last_api_error_detail(); diff --git a/bindings/rgb_lightning_node.udl b/bindings/rgb_lightning_node.udl index dbe504dd..c60835ac 100644 --- a/bindings/rgb_lightning_node.udl +++ b/bindings/rgb_lightning_node.udl @@ -162,7 +162,6 @@ enum RlnError { "FailedVssInit", "Internal", "LightningUnsupportedOnMainnet", - "MainnetLightningState", }; dictionary NodeInfo { diff --git a/openapi.yaml b/openapi.yaml index 1cac960f..00db082a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -15,10 +15,11 @@ info: Bitcoin/RGB on-chain APIs (including RGB invoices, transfers and asset linking) and shared administration/identity APIs retain their existing requirements. Mainnet unlock starts the wallet and signer without a Lightning runtime. - Existing opaque Lightning state requires operator review and causes HTTP 409 - MainnetLightningState; even empty snapshots from older releases are refused. - No Lightning state is deleted or resumed. Lightning APIs on supported - non-mainnet networks are unchanged. + Historical Lightning records remain inactive and do not prevent unlock. They + are not decoded, replayed or recovered. The supported mainnet rollout assumes + no unresolved historical Lightning obligations; unlock does not verify that + assumption or monitor old channels. Lightning APIs on supported non-mainnet + networks are unchanged. license: name: MIT url: https://mit-license.org/ @@ -1359,10 +1360,12 @@ paths: Unlock a locked node. Mainnet starts wallet, signer and configured RGB backup services without constructing or starting LDK. The required ldk_chain_sync payload is parsed but its backend and gossip settings are unused on mainnet. - Existing Lightning state, including opaque empty snapshots from earlier - releases, requires operator recovery review and returns MainnetLightningState. - This refusal does not keep existing channels monitored; do not delete state - to bypass it. + Existing Lightning records remain inactive without preventing wallet unlock. + Node-store persistence updates only common configuration; historical Lightning + records and their pending replication are preserved. RGB wallet backup and + restore retain their separate store. The supported rollout assumes no unresolved + historical mainnet Lightning obligations. Unlock does not verify that assumption, + inspect other devices or remote histories, or monitor old channels. requestBody: content: application/json: @@ -1375,8 +1378,6 @@ paths: application/json: schema: $ref: '#/components/schemas/EmptyResponse' - '409': - $ref: '#/components/responses/MainnetLightningState' /vssbackup: post: tags: @@ -1385,8 +1386,9 @@ paths: description: > Builds a zip of the current RGB wallet directory and uploads it to the configured VSS server. Only available when the node was started with - --vss-url. The node's KV state is replicated automatically on every - persistence event and is not affected by this endpoint. + --vss-url. The node's active KV state is replicated automatically on every + persistence event and is not affected by this endpoint. On mainnet only + common configuration is replicated; historical Lightning state remains inactive. responses: '200': description: Successful operation @@ -1412,7 +1414,9 @@ paths: Returns whether a backup exists on the server, its server-side version, whether the local wallet has changes since the last backup, and how many KV-stream writes are queued for retry after a replication - failure. Read-only; callable by tokens with the read-only role. + failure. On mainnet this counts common configuration retries only, excluding + preserved historical Lightning intents. Read-only; callable by tokens with + the read-only role. responses: '200': description: Successful operation @@ -1448,7 +1452,8 @@ paths: description: > Number of KV writes that failed to replicate and are queued for retry. A persistently non-zero value - indicates VSS replication is degraded. + indicates VSS replication is degraded. On mainnet this excludes + inactive historical Lightning intents. '503': description: VSS is not configured or unreachable /vssclearfence: @@ -1491,24 +1496,6 @@ paths: description: VSS is not configured or unreachable components: responses: - MainnetLightningState: - description: Persisted mainnet Lightning state requires operator recovery review before wallet startup. - content: - application/json: - schema: - type: object - required: [name, code, error] - properties: - name: - type: string - code: - type: integer - error: - type: string - example: - name: MainnetLightningState - code: 409 - error: 'Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: local manager snapshot' LightningUnsupportedOnMainnet: description: Lightning APIs are unavailable on a node configured for mainnet. content: diff --git a/src/error.rs b/src/error.rs index 0b6952fc..7fc36eda 100644 --- a/src/error.rs +++ b/src/error.rs @@ -329,9 +329,6 @@ pub enum APIError { #[error("RLN on mainnet currently supports only on-chain methods. Lightning APIs are not supported.")] LightningUnsupportedOnMainnet, - #[error("Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: {0}")] - MainnetLightningState(String), - #[error("Node is locked (hint: call unlock)")] LockedNode, @@ -719,7 +716,7 @@ impl IntoResponse for APIError { APIError::FailedBitcoindConnection(_) | APIError::NetworkMismatch(_, _) => { (StatusCode::FORBIDDEN, self.to_string(), self.name()) } - APIError::InvoiceAlreadyClaimed | APIError::MainnetLightningState(_) => { + APIError::InvoiceAlreadyClaimed => { (StatusCode::CONFLICT, self.to_string(), self.name()) } APIError::ExternalSignerMismatch => { @@ -840,25 +837,6 @@ mod tests { ); } - #[tokio::test] - async fn mainnet_legacy_state_response_preserves_name_and_message() { - let response = - APIError::MainnetLightningState("local manager snapshot".into()).into_response(); - assert_eq!(response.status(), StatusCode::CONFLICT); - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!( - body, - serde_json::json!({ - "code": 409, - "name": "MainnetLightningState", - "error": "Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: local manager snapshot" - }) - ); - } - #[test] fn unsupported_schema_maps_to_dedicated_error() { let err = APIError::from(RgbLibError::UnsupportedSchema { diff --git a/src/ldk.rs b/src/ldk.rs index e7c569c0..66158bc9 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -4733,24 +4733,6 @@ struct NodeStartup { fence_guard: Option, } -async fn check_mainnet_startup_state( - app_state: &Arc, - #[cfg(feature = "vss")] remote: Option>, -) -> Result<(), APIError> { - let database = app_state.db(); - let data_dir = app_state.static_state.ldk_data_dir.clone(); - tokio::task::spawn_blocking(move || { - crate::mainnet_state::check_mainnet_legacy_state( - database.as_ref(), - &data_dir, - #[cfg(feature = "vss")] - remote.as_deref(), - ) - }) - .await - .map_err(|e| APIError::Unexpected(format!("mainnet state inspection failed: {e}")))? -} - impl NodeStartup { fn create_signer(&self) -> ActiveSignerRef { let external_signer = &self.external_signer; @@ -5226,16 +5208,19 @@ async fn prepare_node( } })); - if mainnet { - check_mainnet_startup_state(&app_state, Some(Arc::clone(&vss_kv_store))).await?; - } let monitor_kv_store = (!mainnet).then(|| { Arc::new(RemoteFirstKvStore::new( Arc::clone(&local_kv_store), Some(Arc::clone(&vss_kv_store)), )) }); - let synced = Arc::new(SyncedKvStore::with_vss(local_kv_store, vss_kv_store)); + // Mainnet retains old Lightning records without loading or replaying them. Only the + // existing common configuration mirrors may be changed or replicated by this session. + let synced = Arc::new(if mainnet { + SyncedKvStore::with_vss_common_config_only(local_kv_store, vss_kv_store) + } else { + SyncedKvStore::with_vss(local_kv_store, vss_kv_store) + }); if !mainnet { // Auto-restore from VSS if local DB has no channel manager data. @@ -5282,22 +5267,22 @@ async fn prepare_node( (synced, monitor_kv_store) } else { - if mainnet { - check_mainnet_startup_state(&app_state, None).await?; - } let monitor_kv_store = (!mainnet) .then(|| Arc::new(RemoteFirstKvStore::new(Arc::clone(&local_kv_store), None))); - let synced = Arc::new(SyncedKvStore::local_only(local_kv_store)); + let synced = Arc::new(if mainnet { + SyncedKvStore::local_common_config_only(local_kv_store) + } else { + SyncedKvStore::local_only(local_kv_store) + }); (synced, monitor_kv_store) }; #[cfg(not(feature = "vss"))] - let kv_store = { - if mainnet { - check_mainnet_startup_state(&app_state).await?; - } - Arc::new(SyncedKvStore::local_only(local_kv_store)) - }; + let kv_store = Arc::new(if mainnet { + SyncedKvStore::local_common_config_only(local_kv_store) + } else { + SyncedKvStore::local_only(local_kv_store) + }); // Sync config from database to KVStore sync_config_to_kvstore(&static_state.db(), kv_store.as_ref())?; diff --git a/src/mainnet_startup_tests.rs b/src/mainnet_startup_tests.rs index 02ba2e30..f013d9d7 100644 --- a/src/mainnet_startup_tests.rs +++ b/src/mainnet_startup_tests.rs @@ -460,23 +460,76 @@ async fn mainnet_rest_unlock_rejects_wrong_indexer_then_serves_wallet() { } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn mainnet_legacy_state_refusal_preserves_bytes_and_does_not_contact_indexer() { +async fn mainnet_legacy_state_stays_inactive_across_wallet_unlock_and_reopen() { use lightning::util::persist::KVStoreSync; + use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait}; let fixture = Fixture::new().await; let store = crate::kv_store::SeaOrmKvStore::from_connection(fixture.state.db()); - store - .write("", "", "manager", b"opaque previous snapshot".to_vec()) - .unwrap(); + // Older on-chain-only starts wrote the replay marker and could persist empty manager/scorer + // snapshots. Opaque/unknown records must stay equally inert; never deserialize to classify them. + let records: &[(&str, &str, &str, &[u8])] = &[ + ("", "", "manager", b"opaque previous manager"), + ("", "", "scorer", b"opaque previous scorer"), + ("", "", "output_sweeper", b"opaque previous sweeper"), + ("reimport_marker", "", "fascia_replay", &[1]), + ("monitors", "", "old_channel", b"opaque previous monitor"), + ("rgb_sender_funding", "", "old_channel", b"old consignment"), + ("vss_pending", "", "_/_/manager", b"\x01queued snapshot"), + ("vss_pending", "", "_/_/scorer", &[0]), + ("vss_pending", "", "unknown", b"malformed intent"), + ]; + for &(primary, secondary, key, value) in records { + store + .write(primary, secondary, key, value.to_vec()) + .unwrap(); + } + let file = fixture.state.static_state.ldk_data_dir.join("old_snapshot"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, b"untouched historical file").unwrap(); + crate::database::entities::ChannelPeerActMod { + pubkey: ActiveValue::Set("historical peer".into()), + address: ActiveValue::Set("127.0.0.1:9735".into()), + created_at: ActiveValue::Set(chrono::Utc::now()), + } + .insert(fixture.state.db().as_ref()) + .await + .unwrap(); + let mut identity = None; for _ in 0..2 { - let result = sdk::unlock(fixture.state.clone(), fixture.request()).await; - assert!(matches!(result, Err(APIError::MainnetLightningState(_)))); + sdk::unlock(fixture.state.clone(), fixture.request()) + .await + .unwrap(); + fixture.assert_no_lightning().await; + let info = sdk::node_info(fixture.state.clone()).await.unwrap(); + assert_eq!((info.num_peers, info.num_channels), (0, 0)); + let address = sdk::address(fixture.state.clone()).await.unwrap().address; + assert!(address.starts_with("bc1")); + if let Some((pubkey, previous_address)) = &identity { + assert_eq!(&info.pubkey, pubkey); + // Address reuse is disabled in this fixture; persisted derivation advances. + assert_ne!(&address, previous_address); + } else { + identity = Some((info.pubkey, address)); + } + assert!(matches!( + sdk::list_peers(fixture.state.clone()).await, + Err(APIError::LightningUnsupportedOnMainnet) + )); + let _ = routes::lock(axum::extract::State(fixture.state.clone())) + .await + .unwrap(); assert!(fixture.state.unlocked_app_state.lock().await.is_none()); assert!(!*fixture.state.changing_state.lock().unwrap()); - assert_eq!( - store.read("", "", "manager").unwrap(), - b"opaque previous snapshot" - ); - assert!(fixture.indexer.requests.lock().unwrap().is_empty()); + for &(primary, secondary, key, value) in records { + assert_eq!(store.read(primary, secondary, key).unwrap(), value); + } + assert_eq!(std::fs::read(&file).unwrap(), b"untouched historical file"); + let peers = crate::database::entities::ChannelPeerEntity::find() + .all(fixture.state.db().as_ref()) + .await + .unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].pubkey, "historical peer"); } } diff --git a/src/mainnet_state.rs b/src/mainnet_state.rs index 98522e30..e64fcd7f 100644 --- a/src/mainnet_state.rs +++ b/src/mainnet_state.rs @@ -1,16 +1,7 @@ -//! Read-only preflight before starting a mainnet wallet without Lightning. -//! -//! LDK does not expose a side-effect-free inspector for its persisted manager or sweeper. -//! Their presence is therefore ambiguous, including snapshots from an old empty node. Do not -//! deserialize them, infer safety from absent monitors, or delete them to make unlock succeed. - -use std::path::Path; +//! Mutation boundary for mainnet shared persistence. Historical Lightning state remains inert; +//! its presence is neither interpreted as an obligation nor used to refuse the on-chain wallet. use lightning::rgb_utils::{RGB_PRIMARY_NS, RGB_WALLET_CONFIG_NS}; -use sea_orm::{DatabaseConnection, EntityTrait, QuerySelect}; - -use crate::database::entities::{ChannelPeerEntity, KvStoreColumn, KvStoreEntity}; -use crate::error::APIError; // These are the existing, reconstructible mirrors written by ldk::save_config. This is an // exact allowlist, not permission to replay arbitrary data under the wallet_config namespace. @@ -23,188 +14,27 @@ const COMMON_CONFIG_KEYS: [&str; 6] = [ "wallet_master_fingerprint", ]; -// Keep recognizing this persisted namespace even in a build without the vss feature. -const PENDING_NAMESPACE: &str = "vss_pending"; - -fn needs_review(detail: impl Into) -> APIError { - APIError::MainnetLightningState(detail.into()) -} - -fn is_common_config(primary: &str, secondary: &str, key: &str) -> bool { +pub(crate) fn is_common_config(primary: &str, secondary: &str, key: &str) -> bool { primary == RGB_PRIMARY_NS && secondary == RGB_WALLET_CONFIG_NS && COMMON_CONFIG_KEYS.contains(&key) } -fn is_common_remote_key(key: &str) -> bool { +#[cfg(feature = "vss")] +pub(crate) fn is_common_remote_key(key: &str) -> bool { COMMON_CONFIG_KEYS .iter() .any(|name| key == format!("{RGB_PRIMARY_NS}/{RGB_WALLET_CONFIG_NS}/{name}")) } -/// Render only bounded key metadata, never a stored value or full filesystem path. -fn key_label(primary: &str, secondary: &str, key: &str) -> String { - fn bounded(value: &str) -> String { - let prefix: String = value.chars().take(64).collect(); - format!("{prefix:?}") - } - format!( - "{}/{}/{}", - bounded(primary), - bounded(secondary), - bounded(key) - ) -} - -fn check_pending_intent(key: &str, value: &[u8]) -> Result<(), APIError> { - // SyncedKvStore stores a one-byte tag followed by a put payload, or just tag 0 for a - // removal. It otherwise loads every intent and may drain it during an unrelated write. - let well_formed = match value.split_first() { - Some((0, payload)) => payload.is_empty(), - Some((1, payload)) => std::str::from_utf8(payload).is_ok(), - _ => false, - }; - if !is_common_remote_key(key) || !well_formed { - return Err(needs_review(format!( - "local pending replication intent {} cannot be replayed by a wallet-only node", - key_label(PENDING_NAMESPACE, "", key) - ))); - } - Ok(()) -} - -fn check_ldk_directory(ldk_data_dir: &Path) -> Result<(), APIError> { - let metadata = match std::fs::symlink_metadata(ldk_data_dir) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(_) => { - return Err(needs_review( - "local Lightning directory cannot be inspected", - )) - } - }; - if !metadata.is_dir() || metadata.file_type().is_symlink() { - return Err(needs_review( - "local Lightning directory is not a regular directory", - )); - } - let entries = std::fs::read_dir(ldk_data_dir) - .map_err(|_| needs_review("local Lightning directory cannot be enumerated"))?; - for entry in entries { - let entry = - entry.map_err(|_| needs_review("local Lightning directory entry is unreadable"))?; - let file_type = entry - .file_type() - .map_err(|_| needs_review("local Lightning directory entry cannot be inspected"))?; - // The shared logger creates this directory even before the first wallet unlock. - if entry.file_name() == crate::utils::LOGS_DIR && file_type.is_dir() { - continue; - } - return Err(needs_review(format!( - "local Lightning directory contains an unclassified entry {}", - key_label("", "", &entry.file_name().to_string_lossy()) - ))); - } - Ok(()) -} - -#[cfg(feature = "vss")] -fn check_remote_keys(keys: &[String]) -> Result<(), APIError> { - for key in keys { - if !is_common_remote_key(key) { - return Err(needs_review(format!( - "remote Lightning store contains an unclassified key {}", - key_label("", "", key) - ))); - } - } - Ok(()) -} - -/// Caller must serialize startup, acquire any configured VSS fence first, and run this on a -/// blocking worker before constructing SyncedKvStore or writing configuration mirrors. This -/// deliberately refuses opaque legacy snapshots; it does not claim they contain live funds. -pub(crate) fn check_mainnet_legacy_state( - database: &DatabaseConnection, - ldk_data_dir: &Path, - #[cfg(feature = "vss")] remote: Option<&crate::vss_kv_store::VssKvStore>, -) -> Result<(), APIError> { - // Select keys first: refusing an opaque manager must not load or decode its payload. - let keys: Vec<(String, String, String)> = crate::runtime::block_on( - KvStoreEntity::find() - .select_only() - .columns([ - KvStoreColumn::PrimaryNamespace, - KvStoreColumn::SecondaryNamespace, - KvStoreColumn::Key, - ]) - .into_tuple() - .all(database), - ) - .map_err(|_| needs_review("local Lightning key inventory cannot be read"))?; - for (primary, secondary, key) in keys { - if is_common_config(&primary, &secondary, &key) { - continue; - } - if primary == PENDING_NAMESPACE && secondary.is_empty() && is_common_remote_key(&key) { - let row = crate::runtime::block_on( - KvStoreEntity::find_by_id((primary, secondary, key.clone())).one(database), - ) - .map_err(|_| needs_review("local pending replication intent cannot be read"))? - .ok_or_else(|| { - needs_review("local pending replication inventory changed during startup") - })?; - check_pending_intent(&key, &row.value)?; - continue; - } - return Err(needs_review(format!( - "local Lightning store contains an unclassified key {}", - key_label(&primary, &secondary, &key) - ))); - } - - if crate::runtime::block_on(ChannelPeerEntity::find().one(database)) - .map_err(|_| needs_review("local Lightning peer inventory cannot be read"))? - .is_some() - { - return Err(needs_review("local Lightning peer history requires review")); - } - check_ldk_directory(ldk_data_dir)?; - - #[cfg(feature = "vss")] - if let Some(remote) = remote { - // list_all_keys excludes the ownership fence and paginates the complete raw inventory. - // No restore, pending-intent cleanup, put or delete is performed here. - let keys = remote - .list_all_keys() - .map_err(|_| needs_review("remote Lightning key inventory cannot be read"))?; - check_remote_keys(&keys)?; - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; - use crate::runtime::block_on; - use sea_orm::{ActiveModelTrait, ActiveValue}; - - // Keep setup, reads and preflight on the repository database runtime: SQLx schedules - // pooled connection releases there, so another runtime cannot drive that queued work. - fn inspect(database: &DatabaseConnection, ldk_data_dir: &Path) -> Result<(), APIError> { - check_mainnet_legacy_state( - database, - ldk_data_dir, - #[cfg(feature = "vss")] - None, - ) - } #[test] - fn only_exact_common_config_locations_are_allowed() { + fn only_exact_common_config_locations_are_mutable() { for key in COMMON_CONFIG_KEYS { assert!(is_common_config("rgb", "wallet_config", key)); - assert!(is_common_remote_key(&format!("rgb/wallet_config/{key}"))); } for (primary, secondary, key) in [ ("", "", "manager"), @@ -214,59 +44,18 @@ mod tests { ("rgb", "wallet_config", "unknown"), ("rgb", "", "indexer_url"), ("rgb", "wallet_config", "indexer_url/manager"), + ("reimport_marker", "", "fascia_replay"), ] { assert!(!is_common_config(primary, secondary, key)); } - assert!(!is_common_remote_key("rgb//wallet_config/indexer_url")); - } - - #[test] - fn pending_config_put_and_delete_are_allowed_but_protocol_or_malformed_intents_are_not() { - let key = "rgb/wallet_config/indexer_url"; - assert!(check_pending_intent(key, b"\x01https://indexer.invalid").is_ok()); - assert!(check_pending_intent(key, &[0]).is_ok()); - for value in [&[][..], &[2][..], &[0, 1][..], &[1, 255][..]] { - assert!(check_pending_intent(key, value).is_err()); - } - for key in ["_/_/manager", "monitor_updates/channel/1", "malformed"] { - assert!(check_pending_intent(key, &[0]).is_err()); - assert!(check_pending_intent(key, &[1]).is_err()); - } - } - - #[test] - fn shared_logs_are_allowed_but_unclassified_files_remain_untouched() { - let temp = tempfile::tempdir().unwrap(); - let ldk_dir = temp.path().join(".ldk"); - assert!(check_ldk_directory(&ldk_dir).is_ok()); - std::fs::create_dir_all(ldk_dir.join(crate::utils::LOGS_DIR)).unwrap(); - std::fs::write( - ldk_dir.join(crate::utils::LOGS_DIR).join("logs.txt"), - b"log", - ) - .unwrap(); - assert!(check_ldk_directory(&ldk_dir).is_ok()); - let state_path = ldk_dir.join("funding_consignment"); - std::fs::write(&state_path, b"protected bytes").unwrap(); - assert!(check_ldk_directory(&ldk_dir).is_err()); - assert_eq!(std::fs::read(state_path).unwrap(), b"protected bytes"); - } - - #[cfg(unix)] - #[test] - fn symbolic_link_is_not_treated_as_the_shared_log_directory() { - let temp = tempfile::tempdir().unwrap(); - let ldk_dir = temp.path().join(".ldk"); - std::fs::create_dir(&ldk_dir).unwrap(); - std::os::unix::fs::symlink(temp.path(), ldk_dir.join(crate::utils::LOGS_DIR)).unwrap(); - assert!(check_ldk_directory(&ldk_dir).is_err()); } #[cfg(feature = "vss")] #[test] - fn remote_only_snapshots_and_noncanonical_keys_are_rejected_without_decoding() { - assert!(check_remote_keys(&[]).is_ok()); - assert!(check_remote_keys(&["rgb/wallet_config/bitcoin_network".into()]).is_ok()); + fn only_canonical_common_remote_keys_are_replayed() { + for key in COMMON_CONFIG_KEYS { + assert!(is_common_remote_key(&format!("rgb/wallet_config/{key}"))); + } for key in [ "_/_/manager", "_/_/output_sweeper", @@ -274,174 +63,10 @@ mod tests { "rgb/pending_funding/id", "vss_pending/_/rgb/wallet_config/indexer_url", "rgb/wallet_config/bitcoin_network/extra", + "rgb//wallet_config/indexer_url", "malformed", ] { - assert!(check_remote_keys(&[key.into()]).is_err()); - } - } - - #[test] - fn diagnostic_keys_are_bounded_and_escape_control_characters() { - let label = key_label("", "", &format!("\n{}", "x".repeat(4096))); - assert!(!label.contains('\n')); - assert!(label.len() < 100); - } - - #[test] - fn database_preflight_accepts_fresh_wallet_and_preserves_ambiguous_snapshots() { - use rln_migration::{Migrator, MigratorTrait}; - - let temp = tempfile::tempdir().unwrap(); - let database = block_on(crate::utils::open_database_pool(temp.path())).unwrap(); - block_on(Migrator::up(&database, None)).unwrap(); - let ldk_data_dir = temp.path().join(".ldk"); - inspect(&database, &ldk_data_dir).expect("fresh wallet has no legacy state"); - - for key in COMMON_CONFIG_KEYS { - block_on( - crate::database::entities::KvStoreActMod { - primary_namespace: ActiveValue::Set("rgb".into()), - secondary_namespace: ActiveValue::Set("wallet_config".into()), - key: ActiveValue::Set(key.into()), - value: ActiveValue::Set(b"common value".to_vec()), - } - .insert(&database), - ) - .unwrap(); - } - inspect(&database, &ldk_data_dir).expect("common configuration is allowed"); - - block_on( - crate::database::entities::KvStoreActMod { - primary_namespace: ActiveValue::Set(String::new()), - secondary_namespace: ActiveValue::Set(String::new()), - key: ActiveValue::Set("manager".into()), - value: ActiveValue::Set(b"opaque legacy snapshot".to_vec()), - } - .insert(&database), - ) - .unwrap(); - let before = block_on(KvStoreEntity::find().all(&database)).unwrap(); - for _ in 0..2 { - let result = inspect(&database, &ldk_data_dir); - assert!( - matches!(&result, Err(APIError::MainnetLightningState(detail)) if detail.contains("unclassified key") && detail.contains("manager")), - "{result:?}" - ); - assert_eq!( - block_on(KvStoreEntity::find().all(&database)).unwrap(), - before - ); - } - block_on(database.close()).unwrap(); - } - - #[test] - fn database_preflight_never_cleans_or_replays_pending_intents() { - use rln_migration::{Migrator, MigratorTrait}; - - let temp = tempfile::tempdir().unwrap(); - let database = block_on(crate::utils::open_database_pool(temp.path())).unwrap(); - block_on(Migrator::up(&database, None)).unwrap(); - for (primary, secondary, key, value, accepted) in [ - ( - PENDING_NAMESPACE, - "", - "rgb/wallet_config/indexer_url", - &b"\x01https://indexer.invalid"[..], - true, - ), - ( - PENDING_NAMESPACE, - "", - "rgb/wallet_config/indexer_url", - &[0][..], - true, - ), - ( - PENDING_NAMESPACE, - "", - "rgb/wallet_config/indexer_url", - &[][..], - false, - ), - ( - PENDING_NAMESPACE, - "", - "rgb/wallet_config/indexer_url", - &[0, 1][..], - false, - ), - (PENDING_NAMESPACE, "", "_/_/manager", &[0][..], false), - (PENDING_NAMESPACE, "", "_/_/manager", &[1][..], false), - ("monitor_updates", "channel", "1", &b"opaque"[..], false), - ] { - block_on( - crate::database::entities::KvStoreActMod { - primary_namespace: ActiveValue::Set(primary.into()), - secondary_namespace: ActiveValue::Set(secondary.into()), - key: ActiveValue::Set(key.into()), - value: ActiveValue::Set(value.to_vec()), - } - .insert(&database), - ) - .unwrap(); - let before = block_on(KvStoreEntity::find().all(&database)).unwrap(); - let result = inspect(&database, &temp.path().join(".ldk")); - assert_eq!( - result.is_ok(), - accepted, - "{primary}/{secondary}/{key}: {result:?}" - ); - if !accepted { - assert!( - matches!(&result, Err(APIError::MainnetLightningState(detail)) if detail.contains(key)), - "{result:?}" - ); - } - assert_eq!( - block_on(KvStoreEntity::find().all(&database)).unwrap(), - before - ); - block_on( - KvStoreEntity::delete_by_id(( - primary.to_owned(), - secondary.to_owned(), - key.to_owned(), - )) - .exec(&database), - ) - .unwrap(); + assert!(!is_common_remote_key(key)); } - block_on(database.close()).unwrap(); - } - - #[test] - fn database_preflight_preserves_peer_history() { - use rln_migration::{Migrator, MigratorTrait}; - - let temp = tempfile::tempdir().unwrap(); - let database = block_on(crate::utils::open_database_pool(temp.path())).unwrap(); - block_on(Migrator::up(&database, None)).unwrap(); - block_on( - crate::database::entities::ChannelPeerActMod { - pubkey: ActiveValue::Set("legacy peer".into()), - address: ActiveValue::Set("127.0.0.1:9735".into()), - created_at: ActiveValue::Set(chrono::Utc::now()), - } - .insert(&database), - ) - .unwrap(); - let before = block_on(ChannelPeerEntity::find().all(&database)).unwrap(); - let result = inspect(&database, &temp.path().join(".ldk")); - assert!( - matches!(&result, Err(APIError::MainnetLightningState(detail)) if detail == "local Lightning peer history requires review"), - "{result:?}" - ); - assert_eq!( - block_on(ChannelPeerEntity::find().all(&database)).unwrap(), - before - ); - block_on(database.close()).unwrap(); } } diff --git a/src/mainnet_vss_tests.rs b/src/mainnet_vss_tests.rs index 754a2acd..f9c88b7d 100644 --- a/src/mainnet_vss_tests.rs +++ b/src/mainnet_vss_tests.rs @@ -524,35 +524,47 @@ async fn mainnet_vss_wallet_backup_reopen_and_fresh_device_restore() { } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn mainnet_vss_remote_only_manager_on_later_page_is_preserved_and_fence_released() { +async fn mainnet_vss_preserves_legacy_records_and_intents_while_wallet_backup_works() { + use lightning::util::persist::KVStoreSync; let server = Server::new().await; let wallet = Wallet::new(&server).await; let node_store = store_id(); + let local = crate::kv_store::SeaOrmKvStore::from_connection(wallet.state.db()); + let legacy_keys = ["_/_/manager", "_/_/scorer", "monitors/_/remote_only"]; { let mut state = server.state.lock().unwrap(); let store = state.stores.entry(node_store.clone()).or_default(); - for key in [ - "rgb/wallet_config/indexer_url", - "rgb/wallet_config/bitcoin_network", - "_/_/manager", - ] { + for key in legacy_keys { store.values.insert( key.into(), KeyValue { key: key.into(), - version: 1, - value: b"opaque bytes must remain untouched".to_vec(), + version: 7, + value: b"remote historical bytes".to_vec(), }, ); } } + local + .write("", "", "manager", b"different local manager".to_vec()) + .unwrap(); + let pending = [ + ("_/_/manager", b"\x01queued manager replacement".as_slice()), + ("_/_/scorer", &[0]), + ("monitors/_/remote_only", b"malformed intent".as_slice()), + ("invalid-key", &[255]), + ]; + for (key, value) in pending { + local.write("vss_pending", "", key, value.to_vec()).unwrap(); + } let before = server.rows(&node_store); - let result = sdk::unlock(wallet.state.clone(), wallet.request()).await; - assert!( - matches!(&result, Err(APIError::MainnetLightningState(detail)) if detail.contains("remote") && detail.contains("manager")), - "{result:?}" - ); - assert_eq!(server.rows(&node_store), before); + let wrong_indexer = Indexer::new(bitcoin::Network::Regtest).await; + let mut wrong_request = wallet.request(); + wrong_request.indexer_url = Some(wrong_indexer.url.clone()); + assert!(matches!( + sdk::unlock(wallet.state.clone(), wrong_request).await, + Err(APIError::InvalidIndexer(_)) + )); assert!(wallet.state.unlocked_app_state.lock().await.is_none()); assert!(wallet .state @@ -561,14 +573,71 @@ async fn mainnet_vss_remote_only_manager_on_later_page_is_preserved_and_fence_re .unwrap() .is_none()); assert!(!*wallet.state.changing_state.lock().unwrap()); + let after_failed_unlock = server.rows(&node_store); + assert!(!after_failed_unlock.contains_key(FENCE)); + for key in legacy_keys { + assert_eq!(after_failed_unlock[key], before[key]); + } + for (key, value) in pending { + assert_eq!(local.read("vss_pending", "", key).unwrap(), value); + } + assert_eq!( + local.read("", "", "manager").unwrap(), + b"different local manager" + ); + for _ in 0..2 { + sdk::unlock(wallet.state.clone(), wallet.request()) + .await + .unwrap(); + let common = { + let state = wallet.state.unlocked_app_state.lock().await; + assert!(state.as_ref().unwrap().lightning.is_none()); + state.as_ref().unwrap().common.clone() + }; + assert!(wallet + .state + .ldk_background_services + .lock() + .unwrap() + .is_none()); + // Exercise explicit drain as well as automatic drains caused by config writes and stop. + common.kv_store.drain_pending(); + assert!(sdk::address(wallet.state.clone()) + .await + .unwrap() + .address + .starts_with("bc1")); + sdk::vss_backup(wallet.state.clone()).await.unwrap(); + wallet.lock().await; + let after = server.rows(&node_store); + assert!(!after.contains_key(FENCE)); + for key in legacy_keys { + assert_eq!(after[key], before[key]); + } + assert_eq!( + local.read("", "", "manager").unwrap(), + b"different local manager" + ); + assert!(local.read("monitors", "", "remote_only").is_err()); + for (key, value) in pending { + assert_eq!(local.read("vss_pending", "", key).unwrap(), value); + } + } + assert!(!server.rows(&format!("{node_store}_rgb"))["backup/data"] + .value + .is_empty()); let state = server.state.lock().unwrap(); - assert!(state.requests.iter().filter(|r| r.method == "list").count() >= 2); - assert!(state.requests.iter().all(|r| r.store == node_store)); assert!(state .requests .iter() - .filter(|r| r.method != "list") - .all(|r| r.keys.iter().chain(&r.deletes).all(|key| key == FENCE))); + .filter(|r| r.store == node_store) + .all(|r| { + r.method != "list" + && r.keys + .iter() + .chain(&r.deletes) + .all(|key| key == FENCE || key.starts_with("rgb/wallet_config/")) + })); } /// The second stop has no session ownership while the first is still stopping its store. diff --git a/src/synced_kv_store.rs b/src/synced_kv_store.rs index 526ccd65..ea991228 100644 --- a/src/synced_kv_store.rs +++ b/src/synced_kv_store.rs @@ -67,10 +67,11 @@ pub(crate) const PENDING_NS: &str = "vss_pending"; /// KVStore wrapper that writes to the local SeaORM store and (optionally) /// replicates to a remote VSS server. Reads always go to the local store for -/// latency. When `remote` is `None`, this behaves identically to a plain -/// `SeaOrmKvStore`. +/// latency. Mainnet uses the common-config-only constructors so historical Lightning +/// records and their replication intents remain inert. pub struct SyncedKvStore { local: Arc, + common_config_only: bool, #[cfg(feature = "vss")] remote: Option>, /// Every local mutation is represented here before VSS is contacted. Each successful VSS @@ -105,6 +106,7 @@ impl SyncedKvStore { pub fn local_only(local: Arc) -> Self { Self { local, + common_config_only: false, #[cfg(feature = "vss")] remote: None, #[cfg(feature = "vss")] @@ -124,6 +126,14 @@ impl SyncedKvStore { } } + /// Opens shared mainnet persistence without permitting Lightning-state mutations. + pub(crate) fn local_common_config_only(local: Arc) -> Self { + Self { + common_config_only: true, + ..Self::local_only(local) + } + } + /// Creates a SyncedKvStore with local storage and VSS replication, /// reloading queued replications persisted by a previous run. #[cfg(feature = "vss")] @@ -131,7 +141,17 @@ impl SyncedKvStore { local: Arc, remote: Arc, ) -> Self { - Self::with_vss_capacity_inner(local, remote, PENDING_QUEUE_CAP) + Self::with_vss_capacity_inner(local, remote, PENDING_QUEUE_CAP, false) + } + + /// Replicates only the existing wallet configuration mirrors. Historical Lightning + /// retry intents are neither loaded nor cleaned up, even if they are malformed. + #[cfg(feature = "vss")] + pub(crate) fn with_vss_common_config_only( + local: Arc, + remote: Arc, + ) -> Self { + Self::with_vss_capacity_inner(local, remote, PENDING_QUEUE_CAP, true) } #[cfg(all(feature = "vss", test))] @@ -140,7 +160,7 @@ impl SyncedKvStore { remote: Arc, pending_capacity: usize, ) -> Self { - Self::with_vss_capacity_inner(local, remote, pending_capacity) + Self::with_vss_capacity_inner(local, remote, pending_capacity, false) } #[cfg(feature = "vss")] @@ -148,6 +168,7 @@ impl SyncedKvStore { local: Arc, remote: Arc, pending_capacity: usize, + common_config_only: bool, ) -> Self { assert!( pending_capacity > 0, @@ -156,7 +177,15 @@ impl SyncedKvStore { let mut pending = std::collections::HashMap::new(); if let Ok(keys) = local.list(PENDING_NS, "") { for key in keys { + if common_config_only && !crate::mainnet_state::is_common_remote_key(&key) { + continue; + } match local.read(PENDING_NS, "", &key) { + Ok(row) if common_config_only && !Self::is_common_config_intent(&row) => { + // Keep uninterpretable bytes until an explicit write to this same + // common-config target supersedes its retry intent. + tracing::warn!(key, "leaving malformed common-config VSS intent unchanged"); + } Ok(row) => match row.split_first() { Some((1, buf)) => { pending.insert(key, Some(buf.to_vec())); @@ -187,6 +216,7 @@ impl SyncedKvStore { } Self { local, + common_config_only, remote: Some(remote), pending: Arc::new(std::sync::Mutex::new(pending)), pending_capacity, @@ -200,6 +230,38 @@ impl SyncedKvStore { } } + fn check_mutation(&self, primary: &str, secondary: &str, key: &str) -> Result<(), io::Error> { + if self.common_config_only + && !crate::mainnet_state::is_common_config(primary, secondary, key) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Mainnet persistence permits only common wallet configuration mutations", + )); + } + Ok(()) + } + + #[cfg(feature = "vss")] + fn is_common_config_intent(row: &[u8]) -> bool { + match row.split_first() { + Some((0, payload)) => payload.is_empty(), + Some((1, payload)) => std::str::from_utf8(payload).is_ok(), + _ => false, + } + } + + #[cfg(feature = "vss")] + fn check_bulk_sync(&self) -> Result<(), io::Error> { + if self.common_config_only { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Mainnet persistence does not restore or replicate historical Lightning state", + )); + } + Ok(()) + } + #[cfg(all(test, feature = "vss"))] pub(crate) fn set_before_drain_gate_hook(&self, hook: Arc) { *self.before_drain_gate_hook.lock().unwrap() = Some(hook); @@ -346,6 +408,7 @@ impl SyncedKvStore { /// the local store is already populated. #[cfg(feature = "vss")] pub(crate) fn restore_from_vss(&self, force: bool) -> Result { + self.check_bulk_sync()?; let Some(ref remote) = self.remote else { return Ok(0); }; @@ -409,6 +472,7 @@ impl SyncedKvStore { /// Refills a VSS store that was wiped or is otherwise incomplete. #[cfg(feature = "vss")] pub(crate) fn push_missing_to_vss(&self) -> Result { + self.check_bulk_sync()?; let Some(ref remote) = self.remote else { return Ok(0); }; @@ -441,6 +505,7 @@ impl SyncedKvStore { key: &str, buf: Vec, ) -> Result<(), io::Error> { + self.check_mutation(primary_namespace, secondary_namespace, key)?; self.local .write(primary_namespace, secondary_namespace, key, buf) } @@ -453,6 +518,7 @@ impl SyncedKvStore { secondary_namespace: &str, key: &str, ) -> Result<(), io::Error> { + self.check_mutation(primary_namespace, secondary_namespace, key)?; self.local .remove(primary_namespace, secondary_namespace, key, false) } @@ -577,6 +643,7 @@ impl SyncedKvStore { key: &str, buf: Vec, ) -> Result<(), io::Error> { + self.check_mutation(primary_namespace, secondary_namespace, key)?; #[cfg(feature = "vss")] if let Some(ref remote) = self.remote { let drain_gate = self.drain_gate.lock().unwrap(); @@ -691,6 +758,7 @@ impl KVStoreSync for SyncedKvStore { key: &str, lazy: bool, ) -> Result<(), io::Error> { + self.check_mutation(primary_namespace, secondary_namespace, key)?; #[cfg(feature = "vss")] if let Some(ref remote) = self.remote { let drain_gate = self.drain_gate.lock().unwrap(); @@ -777,6 +845,218 @@ impl KVStoreSync for SyncedKvStore { } } +#[cfg(test)] +mod common_config_tests { + use super::*; + use rln_migration::MigratorTrait; + + fn local_store() -> (Arc, tempfile::TempDir) { + let directory = tempfile::tempdir().unwrap(); + let database = + crate::runtime::block_on(crate::utils::open_database_pool(directory.path())).unwrap(); + crate::runtime::block_on(rln_migration::Migrator::up(&database, None)).unwrap(); + ( + Arc::new(SeaOrmKvStore::from_connection(Arc::new(database))), + directory, + ) + } + + const PROTECTED_KEYS: [(&str, &str, &str); 7] = [ + ("", "", "manager"), + ("", "", "output_sweeper"), + ("monitors", "", "channel"), + ("monitor_updates", "channel", "1"), + ("rgb", "wallet_config", "unknown"), + ("reimport_marker", "", "fascia_replay"), + ("vss_pending", "", "_/_/manager"), + ]; + + fn assert_protected_mutations_rejected(store: &SyncedKvStore) { + for (primary, secondary, key) in PROTECTED_KEYS { + for result in [ + store.write(primary, secondary, key, b"replacement".to_vec()), + store.remove(primary, secondary, key, false), + store.write_local_only(primary, secondary, key, b"replacement".to_vec()), + ] { + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); + } + #[cfg(feature = "vss")] + assert_eq!( + store + .remove_local_only(primary, secondary, key) + .unwrap_err() + .kind(), + io::ErrorKind::PermissionDenied + ); + assert_eq!(store.read(primary, secondary, key).unwrap(), b"legacy"); + } + } + + #[test] + fn common_only_local_store_preserves_legacy_records_across_reopen() { + let (local, _directory) = local_store(); + for (primary, secondary, key) in PROTECTED_KEYS { + local + .write(primary, secondary, key, b"legacy".to_vec()) + .unwrap(); + } + for _ in 0..2 { + let store = SyncedKvStore::local_common_config_only(Arc::clone(&local)); + assert_protected_mutations_rejected(&store); + store + .write("rgb", "wallet_config", "indexer_url", b"indexer".to_vec()) + .unwrap(); + assert_eq!( + store.read("rgb", "wallet_config", "indexer_url").unwrap(), + b"indexer" + ); + store + .remove("rgb", "wallet_config", "indexer_url", false) + .unwrap(); + assert_eq!( + store + .read("rgb", "wallet_config", "indexer_url") + .unwrap_err() + .kind(), + io::ErrorKind::NotFound + ); + } + } + + #[test] + fn unrestricted_local_store_retains_existing_mutation_behavior() { + let (local, _directory) = local_store(); + let store = SyncedKvStore::local_only(local); + for (primary, secondary, key) in PROTECTED_KEYS { + store + .write(primary, secondary, key, b"legacy".to_vec()) + .unwrap(); + assert_eq!(store.read(primary, secondary, key).unwrap(), b"legacy"); + store.remove(primary, secondary, key, false).unwrap(); + } + } + + #[cfg(feature = "vss")] + fn unreachable_remote() -> Arc { + Arc::new( + crate::vss_kv_store::VssKvStore::new_with_retry( + "http://127.0.0.1:0/vss".into(), + "mainnet-common-config-test".into(), + bitcoin::secp256k1::SecretKey::from_slice(&[1; 32]).unwrap(), + &crate::config::VssSection { + retry_max_attempts: 1, + retry_backoff_ms: 1, + ..Default::default() + }, + ) + .unwrap(), + ) + } + + #[cfg(feature = "vss")] + #[test] + fn common_only_remote_store_rejects_legacy_mutation_and_bulk_sync_before_io() { + let (local, _directory) = local_store(); + for (primary, secondary, key) in PROTECTED_KEYS { + local + .write(primary, secondary, key, b"legacy".to_vec()) + .unwrap(); + } + let store = SyncedKvStore::with_vss_common_config_only(local, unreachable_remote()); + assert_protected_mutations_rejected(&store); + for result in [ + store.restore_from_vss(false), + store.restore_from_vss(true), + store.push_missing_to_vss(), + ] { + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); + } + assert_eq!(store.pending_remote_writes(), 0); + store.drain_pending(); + assert_eq!(store.flush_pending_until(std::time::Instant::now()), 0); + assert_protected_mutations_rejected(&store); + } + + #[cfg(feature = "vss")] + #[test] + fn common_only_retry_queue_preserves_excluded_and_malformed_intents() { + let (local, _directory) = local_store(); + let preserved = [ + ("_/_/manager", b"\x01manager".as_slice()), + ("_/_/output_sweeper", &[0][..]), + ("monitors/_/channel", &[][..]), + ("monitor_updates/channel/1", &[0, 1][..]), + ("unknown/_/key", &[2][..]), + ("not-a-vss-key", &[1, 255][..]), + ("rgb/wallet_config/wallet_fingerprint", &[0, 1][..]), + ( + "rgb/wallet_config/wallet_account_xpub_vanilla", + &[1, 255][..], + ), + ]; + for (key, row) in preserved { + local.write(PENDING_NS, "", key, row.to_vec()).unwrap(); + } + local + .write( + PENDING_NS, + "", + "rgb/wallet_config/indexer_url", + b"\x01indexer".to_vec(), + ) + .unwrap(); + local + .write(PENDING_NS, "", "rgb/wallet_config/bitcoin_network", vec![0]) + .unwrap(); + + for _ in 0..2 { + let store = SyncedKvStore::with_vss_common_config_only( + Arc::clone(&local), + unreachable_remote(), + ); + let expected = std::collections::HashMap::from([ + ( + "rgb/wallet_config/indexer_url".into(), + Some(b"indexer".to_vec()), + ), + ("rgb/wallet_config/bitcoin_network".into(), None), + ]); + assert_eq!(*store.pending.lock().unwrap(), expected); + store.drain_pending(); + assert_eq!(*store.pending.lock().unwrap(), expected); + for (key, row) in preserved { + assert_eq!(local.read(PENDING_NS, "", key).unwrap(), row); + } + } + } + + #[cfg(feature = "vss")] + #[test] + fn common_config_write_supersedes_its_own_malformed_retry_without_touching_others() { + let (local, _directory) = local_store(); + let config_key = "rgb/wallet_config/indexer_url"; + local.write(PENDING_NS, "", config_key, vec![2]).unwrap(); + local.write(PENDING_NS, "", "_/_/manager", vec![2]).unwrap(); + let store = + SyncedKvStore::with_vss_common_config_only(Arc::clone(&local), unreachable_remote()); + assert_eq!(store.pending_remote_writes(), 0); + store + .write( + "rgb", + "wallet_config", + "indexer_url", + b"new-indexer".to_vec(), + ) + .unwrap(); + assert_eq!( + local.read(PENDING_NS, "", config_key).unwrap(), + b"\x01new-indexer" + ); + assert_eq!(local.read(PENDING_NS, "", "_/_/manager").unwrap(), [2]); + assert_eq!(store.pending_remote_writes(), 1); + } +} + #[cfg(all(test, feature = "vss"))] mod stop_tests { use super::*; diff --git a/src/uniffi_api/README.md b/src/uniffi_api/README.md index b37fdda1..898a3dee 100644 --- a/src/uniffi_api/README.md +++ b/src/uniffi_api/README.md @@ -45,12 +45,14 @@ required `ldk_chain_sync` payload is retained but its backend is unused on mainn queries the wallet indexer on demand and can fail if that indexer is unavailable. Identity, signing and configured RGB VSS backup keep their existing keys and stores. -Persisted mainnet Lightning state causes `RlnError::MainnetLightningState` during unlock. -The appended error variant preserves existing error ordinals. This includes opaque -empty snapshots from older on-chain-only wallets; no state is deleted or resumed. -See [mainnet startup and recovery requirements](../../README.md) before upgrading an -existing wallet. Lightning remains available on supported non-mainnet networks with -the same wallet and signing policies. +Existing Lightning records do not prevent mainnet unlock; they remain inactive and are +not decoded, replayed or recovered. Mainnet node-store persistence updates only common +configuration, while RGB wallet backup/restore retains its existing separate store. +This assumes no unresolved historical mainnet Lightning obligations in the supported +rollout; unlock is not a history audit or channel recovery. See the [mainnet startup +assumption and limitations](../../README.md) before upgrading an existing wallet. +Lightning remains available on supported non-mainnet networks with the same wallet and +signing policies. On all networks, canceling an asynchronous Rust SDK unlock caller does not abandon the unlock operation. Shutdown waits for an in-progress unlock to complete before diff --git a/src/uniffi_api/state.rs b/src/uniffi_api/state.rs index 7d3f2b18..6afe63c7 100644 --- a/src/uniffi_api/state.rs +++ b/src/uniffi_api/state.rs @@ -130,7 +130,6 @@ pub(crate) fn map_api_error(err: APIError) -> RlnError { stash_api_error_detail(msg.clone()); match err { APIError::LightningUnsupportedOnMainnet => RlnError::LightningUnsupportedOnMainnet(msg), - APIError::MainnetLightningState(_) => RlnError::MainnetLightningState(msg), APIError::LockedNode | APIError::NotInitialized => RlnError::NotInitialized(msg), APIError::PaymentNotFound(_) | APIError::SwapNotFound(_) diff --git a/src/uniffi_api/tests.rs b/src/uniffi_api/tests.rs index 11ea15b5..7c062f71 100644 --- a/src/uniffi_api/tests.rs +++ b/src/uniffi_api/tests.rs @@ -457,19 +457,6 @@ mod uniffi_smoke_tests { assert!(super::super::state::take_last_api_error_detail().is_none()); } - #[test] - fn uniffi_mainnet_legacy_state_error_preserves_category_and_message() { - let error = crate::error::APIError::MainnetLightningState("local manager snapshot".into()); - let message = error.to_string(); - let mapped = super::super::state::map_api_error(error); - assert!(matches!(mapped, RlnError::MainnetLightningState(_))); - assert_eq!(mapped.to_string(), message); - assert_eq!( - super::super::state::take_last_api_error_detail(), - Some(message) - ); - } - #[test] fn uniffi_errors_preserve_category_and_message() { let err = super::super::state::map_api_error(crate::error::APIError::Unexpected( diff --git a/src/uniffi_api/types.rs b/src/uniffi_api/types.rs index 0524488e..c2a9ad16 100644 --- a/src/uniffi_api/types.rs +++ b/src/uniffi_api/types.rs @@ -58,8 +58,6 @@ pub enum RlnError { Internal(String), #[error("{0}")] LightningUnsupportedOnMainnet(String), - #[error("{0}")] - MainnetLightningState(String), } impl RlnError { From c7f118e2d79b2592e17a6ca649d1067205829d3c Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 6 Oct 2026 13:14:57 +0530 Subject: [PATCH 14/14] Preserve inactive browser Lightning records --- bindings/wasm-sdk/README.md | 51 +-- bindings/wasm-sdk/src/ln_node.rs | 69 +-- bindings/wasm-sdk/src/runtime_store.rs | 337 +++++--------- .../src/tests/mainnet_lightning_tests.rs | 64 +-- .../src/tests/mainnet_preservation_tests.rs | 261 +++++++++++ .../wasm-sdk/src/tests/runtime_store_tests.rs | 428 ++++++++++-------- 6 files changed, 692 insertions(+), 518 deletions(-) create mode 100644 bindings/wasm-sdk/src/tests/mainnet_preservation_tests.rs diff --git a/bindings/wasm-sdk/README.md b/bindings/wasm-sdk/README.md index ee2f8c49..98769078 100644 --- a/bindings/wasm-sdk/README.md +++ b/bindings/wasm-sdk/README.md @@ -18,7 +18,7 @@ For endpoint-level status, see [SDK_WASM_ENDPOINT_MATRIX.md](SDK_WASM_ENDPOINT_M ## Mainnet: on-chain only, without a Lightning runtime -A Mainnet `RlnWasmNode` does not construct an LDK manager, object graph, chain-sync +A Mainnet `RlnWasmNode` does not construct an LDK runtime manager, object graph, chain-sync driver, peer hooks or background Lightning workers. This applies to explicit `newWithNodeRuntimeId(..., "mainnet")` and to a networkless node that adopts a Mainnet wallet. Constructing or inspecting a networkless node leaves it dormant. Its first @@ -27,31 +27,30 @@ the wallet first when another network is intended. A scope already bound to a network or identity cannot be reused with a conflicting one. Compatible handles share the existing runtime without reseeding it. -Before constructing a Mainnet node or attaching its wallet, call -`await sdk.preloadPersistentRuntimeState()` (SDK `init`/`unlock` already preload). -Construction and adoption check the scope's localStorage and durable key inventory -from the preload. An incomplete inventory refuses Mainnet initialization. Any -protected Lightning snapshot, monitor, queue, sweep, RGB Lightning KV or peer state -refuses with `MainnetLightningState`, without decoding, resuming or deleting it: - -```text -MainnetLightningState: Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: protected browser runtime state is present -``` - -This conservative check also refuses historical snapshots from a previously -on-chain-only node. It checks the current browser's IndexedDB inventory and localStorage. -The inventory includes writes made through this SDK after preload, but does not -discover later writes from another tab. It cannot inspect remote-only -`-ldk` VSS recovery state. Neither the -synchronous constructor nor SDK `init`/`unlock` accepts that store's credentials. -Before reusing an identity that previously used remote Lightning storage, an -operator must review the exact VSS server, LDK store and signing identity configured -on the previous device. Wallet `configureVssBackup` configures a separate backup -stream and does not establish that the LDK store is empty. The pinned browser VSS -client cannot list all keys; an absent or empty manifest is insufficient because a -successful object write can precede a failed manifest update. The local check does -not certify remote recovery state. Independent wallet objects remain directly -usable; this node check is not a process-wide wallet restriction. +Existing mainnet wallets may contain idle Lightning snapshots written by older +on-chain-only releases. Mainnet construction and wallet attachment accept those +records without decoding, resuming, deleting or replacing them. No Lightning-state +preload or empty-history check is required to construct or attach a Mainnet node. + +SDK `init`/`unlock` and `preloadPersistentRuntimeState` still preload browser state. +Lightning snapshots, queues, peer/transfer/event records and standalone swap state +are staged in memory; their existing best-effort IndexedDB-to-localStorage copy is +performed only when a Lightning consumer restores the particular key. Mainnet and +unresolved nodes do not restore those views. Supported non-mainnet activation and +standalone runtimes continue restoring their saved state. This also preserves +inactive records when localStorage and IndexedDB contain different bytes. Media, +RGB proxy settings and the shared virtual-channel preference keep their existing +hydration behavior; explicit administrative changes to that preference are allowed. + +This policy assumes no unresolved historical mainnet Lightning obligations in the +supported rollout. Preserving records does not monitor or recover old channels, +certify other devices/tabs or make a future Lightning-enablement/downgrade safe. +Normal mainnet wallet use does not restore or replicate the separate `-ldk` +VSS stream. Wallet `configureVssBackup` remains independent. The browser cannot +certify remote history: SDK `init`/`unlock` do not take LDK store credentials, and +the pinned VSS client lacks complete key listing. An absent manifest is not proof +of empty state. Independent wallet objects and explicitly invoked standalone +transports/administration retain their own behavior. Mainnet rejects peer/connect/reconnect, channel/funding, Lightning invoice/payment, async-payment and event-processing methods, including `chainSyncTick*`, diff --git a/bindings/wasm-sdk/src/ln_node.rs b/bindings/wasm-sdk/src/ln_node.rs index 17fbd426..c25606a7 100644 --- a/bindings/wasm-sdk/src/ln_node.rs +++ b/bindings/wasm-sdk/src/ln_node.rs @@ -428,6 +428,7 @@ pub struct RlnWasmNode { lightning: Rc>>>, live_node_seed: [u8; 32], auto_hooks_installed: Cell, + runtime_views_restored: Cell, peers: Rc>>, channels: Rc>>, payments: Rc>>, @@ -531,9 +532,33 @@ impl RlnWasmNode { *self.configured_network.borrow_mut() = resolved.to_string(); *self.network.borrow_mut() = resolved.to_string(); } + self.restore_runtime_views(); Ok(()) } + fn restore_runtime_views(&self) { + if self.runtime_views_restored.replace(true) { + return; + } + // Cold/mainnet construction must not consume deferred Lightning hydration. Restore + // each handle's views only after a supported network has been selected. + if let Some(snapshot) = + load_runtime_event_log_snapshot(&self.persistence_keys.runtime_events_storage_key) + { + *self.runtime_events.borrow_mut() = snapshot.events; + *self.next_runtime_event_seq.borrow_mut() = snapshot.next_seq; + } + if let Some(snapshot) = load_runtime_rgb_ln_transfer_snapshot( + &self.persistence_keys.rgb_ln_transfers_storage_key, + ) { + *self.rgb_ln_transfers.borrow_mut() = snapshot + .transfers + .into_iter() + .map(|entry| (entry.payment_hash.clone(), entry)) + .collect(); + } + } + fn ensure_runtime_ready(&self) -> Result<(), JsValue> { crate::ensure_sdk_node_runtime_allowed()?; self.prepare_lightning_runtime(true)?; @@ -650,9 +675,6 @@ impl RlnWasmNode { let network_label = network .map(|n| rgb_network_label(n.as_rgb())) .unwrap_or("unknown"); - if network_label == "mainnet" { - crate::runtime_store::check_mainnet_runtime_state(&persistence_keys)?; - } let live_node_seed = derive_node_signing_identity(&proxy_url, normalized_runtime_id.as_deref())? .0 @@ -675,9 +697,6 @@ impl RlnWasmNode { )); } let previous = scope.network.borrow().clone(); - if previous == "mainnet" && network_label == "unknown" { - crate::runtime_store::check_mainnet_runtime_state(&persistence_keys)?; - } if previous != "unknown" && network_label != "unknown" && previous != network_label { return Err(JsValue::from_str( @@ -714,28 +733,6 @@ impl RlnWasmNode { } else { None }; - let runtime_event_snapshot = - load_runtime_event_log_snapshot(&persistence_keys.runtime_events_storage_key); - let runtime_events = runtime_event_snapshot - .as_ref() - .map(|snapshot| snapshot.events.clone()) - .unwrap_or_default(); - let next_runtime_event_seq = runtime_event_snapshot - .as_ref() - .map(|snapshot| snapshot.next_seq) - .unwrap_or(0); - let rgb_ln_transfer_snapshot = - load_runtime_rgb_ln_transfer_snapshot(&persistence_keys.rgb_ln_transfers_storage_key); - let rgb_ln_transfers = rgb_ln_transfer_snapshot - .as_ref() - .map(|snapshot| { - snapshot - .transfers - .iter() - .map(|entry| (entry.payment_hash.clone(), entry.clone())) - .collect::>() - }) - .unwrap_or_default(); let restored_network = runtime_scope.network.borrow().clone(); let node = Self { lightning: Rc::clone(&runtime_scope.lightning), @@ -744,6 +741,7 @@ impl RlnWasmNode { runtime_scope, live_node_seed, auto_hooks_installed: Cell::new(false), + runtime_views_restored: Cell::new(false), proxy_url, node_runtime_id: normalized_runtime_id, persistence_keys, @@ -752,12 +750,12 @@ impl RlnWasmNode { channels: Rc::new(RefCell::new(HashMap::new())), payments: Rc::new(RefCell::new(HashMap::new())), pending_peer_hook_events: Rc::new(RefCell::new(Vec::new())), - runtime_events: Rc::new(RefCell::new(runtime_events)), - rgb_ln_transfers: Rc::new(RefCell::new(rgb_ln_transfers)), + runtime_events: Rc::new(RefCell::new(Vec::new())), + rgb_ln_transfers: Rc::new(RefCell::new(HashMap::new())), next_channel_seq: RefCell::new(0), next_payment_seq: RefCell::new(0), node_instance_nonce: Self::next_node_instance_nonce(), - next_runtime_event_seq: Rc::new(RefCell::new(next_runtime_event_seq)), + next_runtime_event_seq: Rc::new(RefCell::new(0)), network: RefCell::new(restored_network), wallet: RefCell::new(None), relay_session_auth: RefCell::new(None), @@ -848,9 +846,6 @@ impl RlnWasmNode { "runtime scope already uses a different wallet identity", )); } - if wallet_label == "mainnet" { - crate::runtime_store::check_mainnet_runtime_state(&self.persistence_keys)?; - } // All validation precedes policy, wallet and global registry changes. *self.configured_network.borrow_mut() = wallet_label.to_string(); *self.network.borrow_mut() = wallet_label.to_string(); @@ -2206,6 +2201,12 @@ impl RlnWasmNode { #[wasm_bindgen(js_name = listRuntimeEventsValue)] pub fn list_runtime_events_value(&self) -> Result { + if !matches!( + self.configured_network.borrow().as_str(), + "unknown" | "mainnet" + ) { + self.restore_runtime_views(); + } let mut events = self.runtime_events.borrow().clone(); events.sort_by(|a, b| a.seq.cmp(&b.seq)); crate::js_obj(&events) diff --git a/bindings/wasm-sdk/src/runtime_store.rs b/bindings/wasm-sdk/src/runtime_store.rs index 7bab4db5..5e7fbfa9 100644 --- a/bindings/wasm-sdk/src/runtime_store.rs +++ b/bindings/wasm-sdk/src/runtime_store.rs @@ -8,6 +8,10 @@ use wasm_bindgen_futures::{spawn_local, JsFuture}; #[path = "tests/runtime_store_tests.rs"] mod tests; +#[cfg(all(test, target_arch = "wasm32"))] +#[path = "tests/mainnet_preservation_tests.rs"] +mod preservation_tests; + pub(crate) trait RuntimeStateStore { fn get(&self, key: &str) -> Result, JsValue>; fn set(&self, key: &str, value: &str) -> Result<(), JsValue>; @@ -19,17 +23,20 @@ pub(crate) struct BrowserPersistentStateStore; impl RuntimeStateStore for BrowserPersistentStateStore { fn get(&self, key: &str) -> Result, JsValue> { + hydrate_deferred_key(key); local_storage_get_item(key) } fn set(&self, key: &str, value: &str) -> Result<(), JsValue> { local_storage_set_item(key, value)?; + discard_deferred_key(key); persist_to_indexed_db_background(key.to_string(), value.to_string()); Ok(()) } fn delete(&self, key: &str) -> Result<(), JsValue> { local_storage_remove_item(key)?; + discard_deferred_key(key); remove_from_indexed_db_background(key.to_string()); Ok(()) } @@ -58,236 +65,148 @@ pub(crate) async fn preload_runtime_state_from_persistent_store() -> Result<(), hydrate_local_storage_from_indexed_db_prefixes(RUNTIME_STATE_HYDRATE_PREFIXES).await } +// Preload has no selected network. Keep Lightning bytes untouched until a consumer +// actually restores that key; mainnet/cold node paths never perform those reads. #[cfg(target_arch = "wasm32")] #[derive(Default)] -struct DurableStateInventory { +struct DeferredRuntimeState { + entries: std::collections::BTreeMap, revision: u64, - keys: Option, String>>, reads_in_flight: usize, - mutations: std::collections::BTreeMap, + touched: std::collections::BTreeMap, } #[cfg(target_arch = "wasm32")] -struct InventoryRead { +struct PreloadRead { revision: u64, } #[cfg(target_arch = "wasm32")] -impl InventoryRead { +impl PreloadRead { fn begin() -> Self { - DURABLE_STATE_INVENTORY.with(|inventory| { - let mut inventory = inventory.borrow_mut(); - inventory.reads_in_flight += 1; + DEFERRED_RUNTIME_STATE.with(|state| { + let mut state = state.borrow_mut(); + state.reads_in_flight += 1; Self { - revision: inventory.revision, + revision: state.revision, } }) } } #[cfg(target_arch = "wasm32")] -impl Drop for InventoryRead { +impl Drop for PreloadRead { fn drop(&mut self) { - DURABLE_STATE_INVENTORY.with(|inventory| { - let mut inventory = inventory.borrow_mut(); - inventory.reads_in_flight -= 1; - if inventory.reads_in_flight == 0 { - inventory.mutations.clear(); + DEFERRED_RUNTIME_STATE.with(|state| { + let mut state = state.borrow_mut(); + state.reads_in_flight -= 1; + if state.reads_in_flight == 0 { + state.touched.clear(); } }); } } #[cfg(target_arch = "wasm32")] -struct IndexedDbSnapshot { - entries: Vec, - keys: Result, String>, +thread_local! { + static RUNTIME_STATE_PRELOADED: std::cell::RefCell = const { std::cell::RefCell::new(false) }; + static DEFERRED_RUNTIME_STATE: std::cell::RefCell = + std::cell::RefCell::new(DeferredRuntimeState::default()); } #[cfg(target_arch = "wasm32")] -thread_local! { - static RUNTIME_STATE_PRELOADED: std::cell::RefCell = const { std::cell::RefCell::new(false) }; - static DURABLE_STATE_INVENTORY: std::cell::RefCell = const { - std::cell::RefCell::new(DurableStateInventory { - revision: 0, - keys: None, - reads_in_flight: 0, - mutations: std::collections::BTreeMap::new(), - }) - }; +fn is_deferred_runtime_key(key: &str) -> bool { + RUNTIME_STATE_HYDRATE_PREFIXES.iter().any(|prefix| key.starts_with(prefix)) + && !key.starts_with("rln:wasm:media:") + && !key.starts_with("rln:wasm:wallet-rgb-proxy:") + // This is shared configuration, not Lightning recovery state. + && !key.starts_with(crate::wasm_node_persistence::WASM_VIRTUAL_CHANNELS_V0_STORAGE_PREFIX) } +#[cfg(target_arch = "wasm32")] +fn take_deferred_key(key: &str) -> Option { + DEFERRED_RUNTIME_STATE.with(|state| { + let mut state = state.borrow_mut(); + // A write/delete or restore while preload awaits must win over its older listing. + if state.reads_in_flight != 0 { + state.revision = state.revision.wrapping_add(1); + let revision = state.revision; + state.touched.insert(key.to_owned(), revision); + } + state.entries.remove(key) + }) +} + +#[cfg(target_arch = "wasm32")] +fn hydrate_deferred_key(key: &str) { + if let Some(value) = take_deferred_key(key) { + // Retain historical best-effort copying and then read actual localStorage. + let _ = local_storage_set_item(key, &value); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn hydrate_deferred_key(_key: &str) {} + +#[cfg(target_arch = "wasm32")] +fn discard_deferred_key(key: &str) { + let _ = take_deferred_key(key); +} + +#[cfg(not(target_arch = "wasm32"))] +fn discard_deferred_key(_key: &str) {} + #[cfg(target_arch = "wasm32")] async fn hydrate_local_storage_from_indexed_db_prefixes(prefixes: &[&str]) -> Result<(), JsValue> { - let already = RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow()); - let complete = - DURABLE_STATE_INVENTORY.with(|inventory| matches!(inventory.borrow().keys, Some(Ok(_)))); - if already && complete { + if RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow()) { return Ok(()); } - - let read = InventoryRead::begin(); - let snapshot = match indexed_db_list_entries().await { - Ok(snapshot) => snapshot, - Err(error) => { - DURABLE_STATE_INVENTORY.with(|inventory| { - inventory.borrow_mut().keys = Some(Err("IndexedDB listing failed".into())); - }); - // An inventory retry must not change the old one-shot hydration contract. - return if already { Ok(()) } else { Err(error) }; - } - }; - finish_preload( - snapshot, - prefixes, - already, - read.revision, - local_storage_set_item, - ); + let read = PreloadRead::begin(); + let entries = indexed_db_list_entries().await?; + finish_preload(entries, prefixes, read.revision, local_storage_set_item); Ok(()) } #[cfg(target_arch = "wasm32")] fn finish_preload( - snapshot: IndexedDbSnapshot, + entries: Vec, prefixes: &[&str], - already: bool, revision: u64, mut write: impl FnMut(&str, &str) -> Result<(), JsValue>, ) { - if !already { - for entry in snapshot.entries { - if !Array::is_array(&entry) { - continue; - } - let pair = Array::from(&entry); - if pair.length() != 2 { - continue; - } - let key = pair.get(0).as_string(); - let value = pair.get(1).as_string(); - let (Some(key), Some(value)) = (key, value) else { - continue; - }; - if prefixes.iter().any(|prefix| key.starts_with(prefix)) { - // Preserve best-effort hydration on every network. Mainnet inspects the - // durable key inventory directly, even when this copy fails or is skipped. - let _ = write(&key, &value); - } - } + // Concurrent or repeated calls must not restore a consumed stale snapshot. + if RUNTIME_STATE_PRELOADED.with(|loaded| loaded.replace(true)) { + return; } - DURABLE_STATE_INVENTORY.with(|inventory| { - let mut inventory = inventory.borrow_mut(); - inventory.keys = Some(snapshot.keys.map(|mut keys| { - // A listing is an atomic readonly transaction, but successful writes can - // finish while its promise is pending. Overlay the last committed operation - // for each key since this read began; parallel reads retain the journal. - for (key, (changed_at, present)) in &inventory.mutations { - if *changed_at > revision { - if *present { - keys.insert(key.clone()); - } else { - keys.remove(key); - } - } - } - keys - })); - }); - RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow_mut() = true); -} - -#[cfg(target_arch = "wasm32")] -fn record_durable_mutation(key: &str, present: bool) { - DURABLE_STATE_INVENTORY.with(|inventory| { - let mut inventory = inventory.borrow_mut(); - inventory.revision = inventory.revision.wrapping_add(1); - if inventory.reads_in_flight != 0 { - let revision = inventory.revision; - inventory - .mutations - .insert(key.to_owned(), (revision, present)); + for entry in entries { + if !Array::is_array(&entry) { + continue; } - if let Some(Ok(keys)) = inventory.keys.as_mut() { - if present { - keys.insert(key.to_owned()); - } else { - keys.remove(key); - } - } - }); -} - -/// Conservative, read-only preflight. Synchronous node constructors can inspect durable -/// state only after the caller has completed the existing asynchronous preload. The -/// inventory is session-local; it does not discover writes made later by another tab. -pub(crate) fn check_mainnet_runtime_state( - keys: &crate::wasm_node_persistence::RuntimeScopeKeys, -) -> Result<(), JsValue> { - #[cfg(target_arch = "wasm32")] - { - if !RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow()) { - return Err(JsValue::from_str( - "Mainnet node initialization requires await sdk.preloadPersistentRuntimeState() before construction or wallet attachment", - )); + let pair = Array::from(&entry); + if pair.length() != 2 { + continue; } - let storage = web_sys::window() - .ok_or_else(|| JsValue::from_str("browser window unavailable"))? - .local_storage()? - .ok_or_else(|| { - JsValue::from_str("localStorage unavailable for mainnet recovery review") - })?; - let runtime = &keys.ldk_manager_registry_key; - let protected = [ - keys.ldk_runtime_committed_storage_key.clone(), - keys.native_ln_runtime_core_storage_base.clone(), - keys.chain_sync_storage_key.clone(), - keys.runtime_events_storage_key.clone(), - keys.rgb_ln_transfers_storage_key.clone(), - keys.peer_sessions_storage_key.clone(), - format!("rln:wasm:ldk-broadcast-queue:{runtime}"), - format!("rln:wasm:ldk-monitors:{runtime}"), - format!("rln:wasm:ldk-sweeps:{runtime}"), - format!("rln:ldk-kv:{runtime}"), - ]; - let is_protected = |key: &str| { - protected.iter().any(|prefix| { - key == prefix.as_str() - || key - .strip_prefix(prefix.as_str()) - .is_some_and(|suffix| suffix.starts_with(':')) - }) + let (Some(key), Some(value)) = (pair.get(0).as_string(), pair.get(1).as_string()) else { + continue; }; - let durable_protected = DURABLE_STATE_INVENTORY.with(|inventory| { - let inventory = inventory.borrow(); - match inventory.keys.as_ref() { - Some(Ok(keys)) => Ok(keys.iter().any(|key| is_protected(key))), - Some(Err(reason)) => Err(JsValue::from_str(&format!( - "MainnetLightningState: Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: incomplete durable browser state inventory ({reason})" - ))), - None => Err(JsValue::from_str( - "Mainnet node initialization requires await sdk.preloadPersistentRuntimeState() before construction or wallet attachment", - )), - } - })?; - if durable_protected { - return Err(JsValue::from_str( - "MainnetLightningState: Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: protected browser runtime state is present", - )); + if !prefixes.iter().any(|prefix| key.starts_with(prefix)) { + continue; } - for index in 0..storage.length()? { - if let Some(key) = storage.key(index)? { - if is_protected(&key) { - return Err(JsValue::from_str( - "MainnetLightningState: Existing Lightning state requires recovery review before starting this mainnet wallet without Lightning: protected browser runtime state is present", - )); + if is_deferred_runtime_key(&key) { + DEFERRED_RUNTIME_STATE.with(|state| { + let mut state = state.borrow_mut(); + if !state + .touched + .get(&key) + .is_some_and(|changed_at| *changed_at > revision) + { + state.entries.insert(key, value); } - } + }); + } else { + let _ = write(&key, &value); } } - #[cfg(not(target_arch = "wasm32"))] - let _ = keys; - Ok(()) } #[cfg(not(target_arch = "wasm32"))] @@ -421,14 +340,12 @@ export function __rln_runtime_idb_entries() { tx.oncomplete = () => { const keys = keysReq.result || []; const vals = entriesReq.result || []; - const complete = Array.isArray(keysReq.result) && Array.isArray(entriesReq.result) - && keys.length === vals.length; const out = []; for (let i = 0; i < keys.length; i += 1) { out.push([String(keys[i]), typeof vals[i] === "string" ? vals[i] : ""]); } db.close(); - resolve({ entries: out, keys, complete }); + resolve(out); }; tx.onerror = () => { db.close(); reject(tx.error || new Error("indexedDB tx failed")); }; tx.onabort = () => { db.close(); reject(tx.error || new Error("indexedDB tx aborted")); }; @@ -467,75 +384,27 @@ extern "C" { async fn indexed_db_set_item(key: &str, value: &str) -> Result<(), JsValue> { let promise = __rln_runtime_idb_set(key, value); let _ = JsFuture::from(promise).await?; - record_durable_mutation(key, true); + discard_deferred_key(key); Ok(()) } #[cfg(target_arch = "wasm32")] -async fn indexed_db_list_entries() -> Result { +async fn indexed_db_list_entries() -> Result, JsValue> { let promise = __rln_runtime_idb_entries(); let value = JsFuture::from(promise).await?; - Ok(parse_indexed_db_snapshot(&value)) -} - -#[cfg(target_arch = "wasm32")] -fn parse_indexed_db_snapshot(value: &JsValue) -> IndexedDbSnapshot { - let entries = - js_sys::Reflect::get(value, &JsValue::from_str("entries")).unwrap_or(JsValue::UNDEFINED); - let entries_valid = Array::is_array(&entries); - let entries = if entries_valid { - Array::from(&entries).to_vec() - } else { - Vec::new() - }; - let raw_keys = - js_sys::Reflect::get(value, &JsValue::from_str("keys")).unwrap_or(JsValue::UNDEFINED); - let complete = js_sys::Reflect::get(value, &JsValue::from_str("complete")) - .ok() - .and_then(|value| value.as_bool()) - == Some(true); - let keys = (|| { - if !complete || !entries_valid || !Array::is_array(&raw_keys) { - return Err("malformed or incomplete IndexedDB listing".into()); - } - let raw_keys = Array::from(&raw_keys); - if raw_keys.length() as usize != entries.len() { - return Err("IndexedDB key and value counts differ".into()); - } - let mut keys = std::collections::BTreeSet::new(); - for (index, entry) in entries.iter().enumerate() { - let key = raw_keys - .get(index as u32) - .as_string() - .ok_or("non-string IndexedDB key")?; - if !Array::is_array(entry) { - return Err("malformed IndexedDB entry".into()); - } - let pair = Array::from(entry); - if pair.length() != 2 - || pair.get(0).as_string().as_ref() != Some(&key) - || pair.get(1).as_string().is_none() - || !keys.insert(key) - { - return Err("malformed or inconsistent IndexedDB entry".into()); - } - } - Ok(keys) - })(); - IndexedDbSnapshot { entries, keys } + Ok(Array::from(&value).to_vec()) } #[cfg(target_arch = "wasm32")] async fn indexed_db_delete_item(key: &str) -> Result<(), JsValue> { let promise = __rln_runtime_idb_delete(key); let _ = JsFuture::from(promise).await?; - record_durable_mutation(key, false); + discard_deferred_key(key); Ok(()) } #[cfg(all(test, target_arch = "wasm32"))] pub(crate) fn reset_preload_readiness_for_tests() { RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow_mut() = false); - DURABLE_STATE_INVENTORY - .with(|inventory| *inventory.borrow_mut() = DurableStateInventory::default()); + DEFERRED_RUNTIME_STATE.with(|state| *state.borrow_mut() = DeferredRuntimeState::default()); } diff --git a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs index e807b3ad..f588641b 100644 --- a/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs +++ b/bindings/wasm-sdk/src/tests/mainnet_lightning_tests.rs @@ -319,7 +319,7 @@ async fn mainnet_wallet_remains_available_and_adopted_network_restricts_lightnin } #[wasm_bindgen_test(async)] -async fn mainnet_refuses_saved_running_chain_state_without_resuming_or_changing_it() { +async fn mainnet_preserves_saved_running_chain_state_without_resuming_it() { crate::test_utils::reset_wasm_runtime_state_for_tests(); crate::runtime_store::preload_runtime_state_from_persistent_store() .await @@ -337,11 +337,9 @@ async fn mainnet_refuses_saved_running_chain_state_without_resuming_or_changing_ runtime_id.to_string(), "mainnet".to_string(), ); - let error = result.err().expect("protected saved state must refuse"); - assert!(error - .as_string() - .unwrap() - .starts_with("MainnetLightningState:")); + let node = result.expect("mainnet accepts inactive historical state"); + assert!(node.lightning.borrow().is_none()); + drop(node); assert_eq!( storage .get_item(&keys.chain_sync_storage_key) @@ -556,37 +554,23 @@ async fn mainnet_constructor_and_shared_calls_never_enter_lightning_factories() } #[wasm_bindgen_test(async)] -async fn mainnet_preload_is_required_before_constructor_or_adoption_mutates_scope() { +async fn mainnet_constructor_and_adoption_need_no_lightning_preload() { crate::test_utils::reset_wasm_runtime_state_for_tests(); let wallet = mainnet_wallet().await; crate::runtime_store::reset_preload_readiness_for_tests(); let before = test_utils::startup_calls(); let bare = RlnWasmNode::new("ws://mainnet-preload-adoption.invalid".into()).unwrap(); - assert!(bare - .attach_wallet(&wallet) - .unwrap_err() - .as_string() - .unwrap() - .contains("preloadPersistentRuntimeState")); - assert_eq!(bare.configured_network.borrow().as_str(), "unknown"); - assert!(bare.wallet.borrow().is_none()); + bare.attach_wallet(&wallet).unwrap(); + assert_eq!(bare.configured_network.borrow().as_str(), "mainnet"); assert!(bare.lightning.borrow().is_none()); - let proxy = "ws://mainnet-preload.invalid".to_string(); - let result = - RlnWasmNode::new_with_node_runtime_id(proxy.clone(), "preload".into(), "mainnet".into()); - assert!(result - .err() - .unwrap() - .as_string() - .unwrap() - .contains("preloadPersistentRuntimeState")); - assert_eq!(test_utils::startup_calls(), before); - crate::runtime_store::preload_runtime_state_from_persistent_store() - .await - .unwrap(); - let node = - RlnWasmNode::new_with_node_runtime_id(proxy, "preload".into(), "mainnet".into()).unwrap(); + let node = RlnWasmNode::new_with_node_runtime_id( + "ws://mainnet-no-preload.invalid".into(), + "no-preload".into(), + "mainnet".into(), + ) + .unwrap(); assert!(node.lightning.borrow().is_none()); + assert_eq!(test_utils::startup_calls(), before); } #[wasm_bindgen_test(async)] @@ -627,7 +611,7 @@ async fn mainnet_unknown_scope_adoption_and_failed_vss_setup_stay_cold() { } #[wasm_bindgen_test(async)] -async fn mainnet_legacy_protected_namespaces_refuse_without_modification() { +async fn mainnet_legacy_protected_namespaces_remain_inactive_and_unchanged() { crate::test_utils::reset_wasm_runtime_state_for_tests(); crate::runtime_store::preload_runtime_state_from_persistent_store() .await @@ -654,23 +638,17 @@ async fn mainnet_legacy_protected_namespaces_refuse_without_modification() { .unwrap(); let result = RlnWasmNode::new_with_node_runtime_id(proxy.into(), id.into(), "mainnet".into()); - assert!(result - .err() - .unwrap() - .as_string() - .unwrap() - .starts_with("MainnetLightningState:")); + let node = result.expect("legacy bytes do not prevent mainnet construction"); + assert!(node.lightning.borrow().is_none()); + drop(node); assert_eq!( storage.get_item(&key).unwrap().as_deref(), Some("unknown-or-corrupt-legacy-state") ); let inherited = RlnWasmNode::new_with_runtime_id_opt(proxy.into(), Some(id.into()), None); - assert!(inherited - .err() - .unwrap() - .as_string() - .unwrap() - .starts_with("MainnetLightningState:")); + let inherited = inherited.expect("compatible mainnet handle"); + assert!(inherited.lightning.borrow().is_none()); + drop(inherited); storage.remove_item(&key).unwrap(); } assert_eq!(test_utils::startup_calls(), before); diff --git a/bindings/wasm-sdk/src/tests/mainnet_preservation_tests.rs b/bindings/wasm-sdk/src/tests/mainnet_preservation_tests.rs new file mode 100644 index 00000000..c43f087e --- /dev/null +++ b/bindings/wasm-sdk/src/tests/mainnet_preservation_tests.rs @@ -0,0 +1,261 @@ +use super::*; +use crate::wasm_node_persistence::RuntimeScopeKeys; +use crate::{RlnWasmNode, RlnWasmSdk, RlnWasmWallet}; +use std::collections::BTreeMap; +use wasm_bindgen_test::wasm_bindgen_test; + +#[derive(Debug, PartialEq)] +struct StoredState { + local: BTreeMap, + durable: BTreeMap, +} + +async fn scoped_state(scope: &str) -> Result { + let storage = web_sys::window().unwrap().local_storage()?.unwrap(); + let mut local = BTreeMap::new(); + for index in 0..storage.length()? { + if let Some(key) = storage.key(index)? { + if key.contains(scope) { + if let Some(value) = storage.get_item(&key)? { + local.insert(key, value); + } + } + } + } + let mut durable = BTreeMap::new(); + for entry in indexed_db_list_entries().await? { + let pair = Array::from(&entry); + if let (Some(key), Some(value)) = (pair.get(0).as_string(), pair.get(1).as_string()) { + if key.contains(scope) { + durable.insert(key, value); + } + } + } + Ok(StoredState { local, durable }) +} + +async fn remove_fixture(scope: &str) -> Result<(), JsValue> { + let state = scoped_state(scope).await?; + for key in state.local.keys().chain(state.durable.keys()) { + local_storage_remove_item(key)?; + indexed_db_delete_item(key).await?; + } + Ok(()) +} + +async fn seed_legacy_records( + keys: &RuntimeScopeKeys, + saved_running: bool, +) -> Result { + // Capture actual serialized empty/stopped driver state, as older wallet-only starts could + // persist it. Construct this fixture before measuring production lifecycle startup calls. + let driver = crate::chain_sync::WasmChainSyncDriver::new( + keys.ldk_manager_registry_key.clone(), + "mainnet".into(), + )?; + let stopped = local_storage_get_item(&keys.chain_sync_storage_key)?.unwrap(); + drop(driver); + indexed_db_set_item(&keys.chain_sync_storage_key, &stopped).await?; + let saved_chain = if saved_running { + // Use the same real snapshot schema without ever starting its timer or indexer. + let mut running: serde_json::Value = serde_json::from_str(&stopped).unwrap(); + running["running"] = serde_json::json!(true); + running["indexer_url"] = serde_json::json!("https://must-not-resume.invalid"); + running.to_string() + } else { + stopped + }; + + let protected = [ + keys.chain_sync_storage_key.clone(), + keys.ldk_runtime_committed_storage_key.clone(), + keys.ldk_runtime_pending_storage_key.clone(), + format!("{}:channel-manager", keys.ldk_runtime_committed_storage_key), + format!( + "{}:channel-manager:pending", + keys.ldk_runtime_committed_storage_key + ), + format!("{}:network-graph", keys.ldk_runtime_committed_storage_key), + format!( + "{}:network-graph:pending", + keys.ldk_runtime_committed_storage_key + ), + format!("{}:scorer", keys.ldk_runtime_committed_storage_key), + format!("{}:scorer:pending", keys.ldk_runtime_committed_storage_key), + format!("{}:committed", keys.native_ln_runtime_core_storage_base), + format!("{}:pending", keys.native_ln_runtime_core_storage_base), + keys.runtime_events_storage_key.clone(), + keys.rgb_ln_transfers_storage_key.clone(), + keys.peer_sessions_storage_key.clone(), + format!("rln:wasm:ldk-monitors:{}", keys.ldk_manager_registry_key), + format!( + "rln:wasm:ldk-monitors:{}:pending", + keys.ldk_manager_registry_key + ), + format!( + "rln:wasm:ldk-monitors:{}:index", + keys.ldk_manager_registry_key + ), + format!( + "rln:wasm:ldk-monitors:{}:monitor:old-channel", + keys.ldk_manager_registry_key + ), + format!( + "rln:wasm:ldk-broadcast-queue:{}", + keys.ldk_manager_registry_key + ), + format!("rln:wasm:ldk-sweeps:{}", keys.ldk_manager_registry_key), + format!("rln:ldk-kv:{}:::manager", keys.ldk_manager_registry_key), + format!( + "rln:ldk-kv:{}:monitors::old-channel", + keys.ldk_manager_registry_key + ), + format!("rln:wasm:swap-runtime:{}", keys.runtime_scope_key), + ]; + for (index, key) in protected.iter().enumerate() { + // Divergent caches, durable-only and cache-only records all need preservation. Invalid + // JSON is deliberate: successful on-chain lifecycle must not decode these payloads. + let local = if index == 0 { + let mut divergent: serde_json::Value = serde_json::from_str(&saved_chain).unwrap(); + divergent["running"] = serde_json::json!(!saved_running); + divergent["indexer_url"] = serde_json::json!("https://must-not-resume.invalid"); + divergent["latest_tip_height"] = serde_json::json!(42); + divergent.to_string() + } else { + format!("opaque local record {index}: {{\u{0}unfinished") + }; + let durable = if index == 0 { + saved_chain.clone() + } else { + format!("opaque durable record {index}: [\u{0}unfinished") + }; + match index % 3 { + 0 => { + local_storage_set_item(key, &local)?; + indexed_db_set_item(key, &durable).await?; + } + 1 => { + local_storage_remove_item(key)?; + indexed_db_set_item(key, &durable).await?; + } + _ => { + local_storage_set_item(key, &local)?; + indexed_db_delete_item(key).await?; + } + } + } + scoped_state(&keys.runtime_scope_key).await +} + +fn mainnet_wallet_data() -> serde_json::Value { + let mut data: serde_json::Value = + serde_json::from_str(&crate::test_utils::test_wallet_data_json()).unwrap(); + let keys = rgb_lib_wasm::restore_keys( + rgb_lib_wasm::BitcoinNetwork::Mainnet, + data["mnemonic"].as_str().unwrap().to_owned(), + ) + .unwrap(); + data["bitcoin_network"] = serde_json::json!("Mainnet"); + data["supported_schemas"] = serde_json::json!(["Nia"]); + data["account_xpub_vanilla"] = serde_json::json!(keys.account_xpub_vanilla); + data["account_xpub_colored"] = serde_json::json!(keys.account_xpub_colored); + data +} + +async fn exercise_wallet_lifecycle( + proxy: &str, + runtime_id: &str, + explicit: bool, + scope: &str, +) -> Result, JsValue> { + let sdk = RlnWasmSdk::new(); + let wallet_data = mainnet_wallet_data(); + sdk.init_json( + "preservation-password".into(), + Some(wallet_data["mnemonic"].as_str().unwrap().into()), + ) + .await?; + let mut observed = vec![scoped_state(scope).await?]; + for _ in 0..2 { + sdk.unlock(r#"{"password":"preservation-password"}"#.into()) + .await?; + sdk.preload_persistent_runtime_state().await?; + observed.push(scoped_state(scope).await?); + let wallet = RlnWasmWallet::create(&wallet_data.to_string()).await?; + let node = RlnWasmNode::new_with_runtime_id_opt( + proxy.into(), + Some(runtime_id.into()), + explicit.then_some(crate::WasmRlnNetwork::Mainnet), + )?; + // Bare construction precedes network adoption: it must not restore old Lightning views. + observed.push(scoped_state(scope).await?); + node.attach_wallet(&wallet)?; + if !wallet.get_address()?.starts_with("bc1") { + return Err(JsValue::from_str( + "mainnet wallet did not return a bc1 address", + )); + } + node.sign_message_json("inactive legacy wallet".into())?; + node.node_pubkey_json()?; + node.chain_sync_status_json()?; + node.ldk_runtime_status_json()?; + node.native_runtime_core_status_json()?; + let unavailable = node.network_info_json().err().and_then(|e| e.as_string()); + if !unavailable.is_some_and(|e| e.starts_with("NetworkInfoUnavailable:")) { + return Err(JsValue::from_str( + "mainnet must not expose an old Lightning chain tip", + )); + } + let rejected = node.list_channels_json().err().and_then(|e| e.as_string()); + if rejected.as_deref() != Some("LightningUnsupportedOnMainnet: RLN on mainnet currently supports only on-chain methods. Lightning APIs are not supported.") { + return Err(JsValue::from_str("mainnet Lightning call did not retain its error")); + } + let shared = RlnWasmNode::new_with_node_runtime_id( + proxy.into(), + runtime_id.into(), + "mainnet".into(), + )?; + shared.node_pubkey_json()?; + shared.chain_sync_status_json()?; + observed.push(scoped_state(scope).await?); + sdk.lock().await?; + drop(shared); + drop(node); + drop(wallet); + // Model another page session reading durable storage again after all node handles drop. + reset_preload_readiness_for_tests(); + sdk.preload_persistent_runtime_state().await?; + observed.push(scoped_state(scope).await?); + } + Ok(observed) +} + +#[wasm_bindgen_test(async)] +async fn mainnet_sdk_lifecycle_preserves_divergent_and_asymmetric_legacy_storage() { + for explicit in [true, false] { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + crate::peer_session::clear_rln_ldk_peer_manager_hooks(); + let proxy = "ws://mainnet-preservation-lifecycle.invalid"; + let runtime_id = if explicit { "explicit" } else { "adopted" }; + let keys = + RuntimeScopeKeys::from_runtime_scope_key(format!("{proxy}#runtime:{runtime_id}")); + remove_fixture(&keys.runtime_scope_key).await.unwrap(); + let expected = seed_legacy_records(&keys, !explicit).await.unwrap(); + reset_preload_readiness_for_tests(); + let before = crate::ln_node::test_utils::startup_calls(); + let result = + exercise_wallet_lifecycle(proxy, runtime_id, explicit, &keys.runtime_scope_key).await; + let after = crate::ln_node::test_utils::startup_calls(); + let hooks = crate::peer_session::has_peer_manager_hooks(); + // Remove every scoped record, including any unexpected writes, before assertions so a + // failure cannot poison later tests or be rehydrated by another SDK initialization. + remove_fixture(&keys.runtime_scope_key).await.unwrap(); + reset_preload_readiness_for_tests(); + crate::test_utils::reset_wasm_runtime_state_for_tests(); + for observed in result.expect("mainnet on-chain lifecycle with inactive legacy state") { + assert_eq!(observed, expected, "explicit mainnet = {explicit}"); + } + assert_eq!(after, before, "no Lightning factories may run"); + assert!(!hooks, "no global peer hooks may be installed"); + } +} diff --git a/bindings/wasm-sdk/src/tests/runtime_store_tests.rs b/bindings/wasm-sdk/src/tests/runtime_store_tests.rs index bf407ff3..868807a7 100644 --- a/bindings/wasm-sdk/src/tests/runtime_store_tests.rs +++ b/bindings/wasm-sdk/src/tests/runtime_store_tests.rs @@ -30,240 +30,306 @@ mod browser { use super::super::*; use wasm_bindgen_test::wasm_bindgen_test; - fn scope(name: &str) -> crate::wasm_node_persistence::RuntimeScopeKeys { - crate::wasm_node_persistence::RuntimeScopeKeys::from_runtime_scope_key(format!( - "ws://runtime-inventory-{name}.invalid#runtime:identity" - )) - } - - fn snapshot_value(keys: &[&str]) -> JsValue { - let raw_keys = Array::new(); - let entries = Array::new(); - for key in keys { - raw_keys.push(&JsValue::from_str(key)); - let pair = Array::new(); - pair.push(&JsValue::from_str(key)); - pair.push(&JsValue::from_str("preserved bytes")); - entries.push(&pair); - } - let value = js_sys::Object::new(); - js_sys::Reflect::set(&value, &"keys".into(), &raw_keys).unwrap(); - js_sys::Reflect::set(&value, &"entries".into(), &entries).unwrap(); - js_sys::Reflect::set(&value, &"complete".into(), &JsValue::TRUE).unwrap(); - value.into() - } - - fn mainnet_error(keys: &crate::wasm_node_persistence::RuntimeScopeKeys) -> String { - check_mainnet_runtime_state(keys) - .unwrap_err() - .as_string() - .unwrap() + fn entries(values: &[(&str, &str)]) -> Vec { + values + .iter() + .map(|(key, value)| { + let pair = Array::new(); + pair.push(&JsValue::from_str(key)); + pair.push(&JsValue::from_str(value)); + pair.into() + }) + .collect() } #[wasm_bindgen_test(async)] - async fn mainnet_preflight_detects_durable_kv_and_sweeps_without_hydrating_them() { - let keys = scope("protected"); - let protected = [ - format!( - "rln:ldk-kv:{}:monitors:monitor_updates:pending", - keys.ldk_manager_registry_key - ), - format!("rln:wasm:ldk-sweeps:{}", keys.ldk_manager_registry_key), - ]; + async fn preload_preserves_inactive_lightning_cache_and_durable_bytes() { + let key = "rln:wasm:chain-sync:node-runtime:preserved-upgrade"; let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); - for key in &protected { - indexed_db_set_item(key, "preserve-remote-format-bytes") - .await - .unwrap(); - storage.remove_item(key).unwrap(); - } + indexed_db_set_item(key, "durable Lightning bytes") + .await + .unwrap(); + storage + .set_item(key, "different local Lightning bytes") + .unwrap(); reset_preload_readiness_for_tests(); preload_runtime_state_from_persistent_store().await.unwrap(); - for key in &protected { - assert_eq!(storage.get_item(key).unwrap(), None); - } - assert!(mainnet_error(&keys).starts_with("MainnetLightningState:")); + let actual = storage.get_item(key).unwrap(); let durable = indexed_db_list_entries().await.unwrap(); - for key in &protected { - assert!(durable.entries.iter().any(|entry| { - let pair = Array::from(entry); - pair.get(0).as_string().as_ref() == Some(key) - && pair.get(1).as_string().as_deref() == Some("preserve-remote-format-bytes") - })); - indexed_db_delete_item(key).await.unwrap(); - } - check_mainnet_runtime_state(&keys).unwrap(); + storage.remove_item(key).unwrap(); + indexed_db_delete_item(key).await.unwrap(); + assert_eq!(actual.as_deref(), Some("different local Lightning bytes")); + assert!(durable.iter().any(|entry| { + let pair = Array::from(entry); + pair.get(0).as_string().as_deref() == Some(key) + && pair.get(1).as_string().as_deref() == Some("durable Lightning bytes") + })); } #[wasm_bindgen_test] - fn mainnet_preflight_uses_inventory_when_best_effort_copy_fails() { - let keys = scope("copy-error"); - let protected = keys.chain_sync_storage_key.clone(); - let allowed = "rln:wasm:media:mainnet-copy-error"; - let value = snapshot_value(&[allowed, &protected]); + fn preload_defers_lightning_per_key_and_retains_common_best_effort_copy() { reset_preload_readiness_for_tests(); - let mut attempted = Vec::new(); + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let supported = "rln:wasm:runtime-events:supported-hydration"; + let mainnet = "rln:wasm:runtime-events:mainnet-hydration"; + let swap = "rln:wasm:swap-runtime:inactive-hydration"; + let common = "rln:wasm:media:common-hydration"; + for key in [supported, mainnet, swap] { + storage.set_item(key, "local").unwrap(); + } + let mut copies = Vec::new(); finish_preload( - parse_indexed_db_snapshot(&value), + entries(&[ + (supported, "durable"), + (mainnet, "durable"), + (swap, "durable"), + (common, "media"), + ]), RUNTIME_STATE_HYDRATE_PREFIXES, - false, 0, |key, _| { - attempted.push(key.to_owned()); - Err(JsValue::from_str("simulated quota failure")) + copies.push(key.to_string()); + Err(JsValue::from_str("simulated quota error")) }, ); - assert_eq!(attempted, [allowed.to_owned(), protected]); - assert!(RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow())); - assert!(mainnet_error(&keys).contains("protected browser runtime state")); - - // Copy failures for unrelated data must not become a new Mainnet refusal. - reset_preload_readiness_for_tests(); + assert_eq!(copies, [common]); + assert_eq!( + storage.get_item(supported).unwrap().as_deref(), + Some("local") + ); + assert_eq!( + browser_persistent_state_store() + .get(supported) + .unwrap() + .as_deref(), + Some("durable") + ); + assert_eq!(storage.get_item(mainnet).unwrap().as_deref(), Some("local")); + assert_eq!(storage.get_item(swap).unwrap().as_deref(), Some("local")); + storage.set_item(supported, "new local").unwrap(); + assert_eq!( + browser_persistent_state_store() + .get(supported) + .unwrap() + .as_deref(), + Some("new local") + ); finish_preload( - parse_indexed_db_snapshot(&snapshot_value(&[allowed])), + entries(&[(supported, "obsolete")]), RUNTIME_STATE_HYDRATE_PREFIXES, - false, 0, - |_, _| Err(JsValue::from_str("simulated quota failure")), + |_, _| panic!("one-shot preload"), ); - check_mainnet_runtime_state(&keys).unwrap(); - } - - #[wasm_bindgen_test] - fn mainnet_preflight_rejects_malformed_or_incomplete_inventory() { - let keys = scope("invalid-inventory"); - let missing = js_sys::Object::new().into(); - let incomplete = snapshot_value(&[]); - js_sys::Reflect::set(&incomplete, &"complete".into(), &JsValue::FALSE).unwrap(); - let mismatched_count = snapshot_value(&["valid-key"]); - js_sys::Reflect::set(&mismatched_count, &"entries".into(), &Array::new()).unwrap(); - let non_string_key = snapshot_value(&["valid-key"]); - let raw_keys = Array::new(); - raw_keys.push(&JsValue::from_f64(7.0)); - js_sys::Reflect::set(&non_string_key, &"keys".into(), &raw_keys).unwrap(); - let malformed_entry = snapshot_value(&["valid-key"]); - let entries = Array::new(); - entries.push(&JsValue::NULL); - js_sys::Reflect::set(&malformed_entry, &"entries".into(), &entries).unwrap(); - let inconsistent_key = snapshot_value(&["valid-key"]); - let raw_keys = Array::new(); - raw_keys.push(&JsValue::from_str("different-key")); - js_sys::Reflect::set(&inconsistent_key, &"keys".into(), &raw_keys).unwrap(); - for value in [ - missing, - incomplete, - mismatched_count, - non_string_key, - malformed_entry, - inconsistent_key, - ] { - reset_preload_readiness_for_tests(); - finish_preload( - parse_indexed_db_snapshot(&value), - RUNTIME_STATE_HYDRATE_PREFIXES, - false, - 0, - |_, _| Ok(()), - ); - assert!(RUNTIME_STATE_PRELOADED.with(|loaded| *loaded.borrow())); - assert!(mainnet_error(&keys).contains("incomplete durable browser state inventory")); + assert_eq!( + browser_persistent_state_store() + .get(supported) + .unwrap() + .as_deref(), + Some("new local") + ); + for key in [supported, mainnet, swap] { + storage.remove_item(key).unwrap(); } reset_preload_readiness_for_tests(); - assert!(mainnet_error(&keys).contains("preloadPersistentRuntimeState")); } #[wasm_bindgen_test(async)] - async fn mainnet_inventory_tracks_successful_durable_writes_and_deletes() { - let keys = scope("mutations"); - let key = format!("rln:ldk-kv:{}:manager", keys.ldk_manager_registry_key); + async fn deferred_hydration_never_resurrects_written_or_deleted_values() { reset_preload_readiness_for_tests(); - preload_runtime_state_from_persistent_store().await.unwrap(); - check_mainnet_runtime_state(&keys).unwrap(); - indexed_db_set_durable(key.clone(), "new durable state".into()) - .await - .unwrap(); - assert!(mainnet_error(&keys).contains("protected browser runtime state")); - indexed_db_delete_item(&key).await.unwrap(); - check_mainnet_runtime_state(&keys).unwrap(); + let changed = "rln:wasm:ldk-runtime:deferred-write"; + let deleted = "rln:wasm:ldk-runtime:deferred-delete"; + finish_preload( + entries(&[(changed, "old"), (deleted, "old")]), + RUNTIME_STATE_HYDRATE_PREFIXES, + 0, + |_, _| Ok(()), + ); + let store = browser_persistent_state_store(); + store.set(changed, "new").unwrap(); + store.delete(deleted).unwrap(); + assert_eq!(store.get(changed).unwrap().as_deref(), Some("new")); + assert_eq!(store.get(deleted).unwrap(), None); + // Drain prior background mutations before removing the fixture from both stores. + indexed_db_set_item(changed, "new").await.unwrap(); + indexed_db_delete_item(changed).await.unwrap(); + indexed_db_delete_item(deleted).await.unwrap(); + local_storage_remove_item(changed).unwrap(); } #[wasm_bindgen_test(async)] - async fn mainnet_inventory_merges_mutations_during_overlapping_preloads() { - let keys = scope("concurrent-write"); - let key = format!("rln:ldk-kv:{}:manager", keys.ldk_manager_registry_key); + async fn mutations_during_overlapping_preloads_cannot_stage_stale_values() { reset_preload_readiness_for_tests(); - let first = InventoryRead::begin(); - let first_snapshot = indexed_db_list_entries().await.unwrap(); - indexed_db_set_durable(key.clone(), "first committed write".into()) + let changed = "rln:wasm:ldk-runtime:inflight-write"; + let deleted = "rln:wasm:ldk-runtime:inflight-delete"; + let read = "rln:wasm:ldk-runtime:inflight-read"; + let durable_write = "rln:wasm:ldk-runtime:inflight-durable-write"; + let durable_delete = "rln:wasm:ldk-runtime:inflight-durable-delete"; + let first = PreloadRead::begin(); + let second = PreloadRead::begin(); + let store = browser_persistent_state_store(); + store.set(changed, "current").unwrap(); + store.delete(deleted).unwrap(); + local_storage_set_item(read, "already restored").unwrap(); + assert_eq!( + store.get(read).unwrap().as_deref(), + Some("already restored") + ); + local_storage_set_item(durable_write, "current cache").unwrap(); + local_storage_remove_item(durable_delete).unwrap(); + indexed_db_set_durable(durable_write.into(), "committed".into()) .await .unwrap(); - let second = InventoryRead::begin(); - let second_snapshot = indexed_db_list_entries().await.unwrap(); - indexed_db_delete_item(&key).await.unwrap(); + indexed_db_delete_item(durable_delete).await.unwrap(); + let snapshot = || { + entries(&[ + (changed, "old"), + (deleted, "old"), + (read, "old"), + (durable_write, "old"), + (durable_delete, "old"), + ]) + }; finish_preload( - second_snapshot, + snapshot(), RUNTIME_STATE_HYDRATE_PREFIXES, - false, - second.revision, + first.revision, |_, _| Ok(()), ); + drop(first); + finish_preload( + snapshot(), + RUNTIME_STATE_HYDRATE_PREFIXES, + second.revision, + |_, _| panic!("late preload must not republish"), + ); drop(second); - // The write-then-delete overlay removes the key from the second snapshot. - check_mainnet_runtime_state(&keys).unwrap(); + assert_eq!(store.get(changed).unwrap().as_deref(), Some("current")); + assert_eq!(store.get(deleted).unwrap(), None); assert_eq!( - DURABLE_STATE_INVENTORY.with(|inventory| inventory.borrow().reads_in_flight), - 1 + store.get(read).unwrap().as_deref(), + Some("already restored") ); - - indexed_db_set_durable(key.clone(), "write after deletion".into()) - .await - .unwrap(); - finish_preload( - first_snapshot, - RUNTIME_STATE_HYDRATE_PREFIXES, - true, - first.revision, - |_, _| panic!("an inventory-only refresh must not hydrate again"), + assert_eq!( + store.get(durable_write).unwrap().as_deref(), + Some("current cache") ); - drop(first); - // The delete-then-write overlay preserves the latest committed protected key, - // even though the first snapshot predates both writes. - assert!(mainnet_error(&keys).contains("protected browser runtime state")); - DURABLE_STATE_INVENTORY.with(|inventory| { - let inventory = inventory.borrow(); - assert_eq!(inventory.reads_in_flight, 0); - assert!( - inventory.mutations.is_empty(), - "completed reads must not retain tombstones" - ); + assert_eq!(store.get(durable_delete).unwrap(), None); + DEFERRED_RUNTIME_STATE.with(|state| { + assert!(state.borrow().touched.is_empty()); + assert_eq!(state.borrow().reads_in_flight, 0); }); - indexed_db_delete_item(&key).await.unwrap(); - check_mainnet_runtime_state(&keys).unwrap(); + indexed_db_set_item(changed, "current").await.unwrap(); + for key in [changed, deleted, read, durable_write, durable_delete] { + indexed_db_delete_item(key).await.unwrap(); + local_storage_remove_item(key).unwrap(); + } } #[wasm_bindgen_test] - fn mainnet_preflight_checks_current_local_storage_and_scope_boundaries() { - let keys = scope("local-state"); - let protected = format!("rln:ldk-kv:{}", keys.ldk_manager_registry_key); - let neighbor = format!("{protected}-other-scope:manager"); + fn standalone_chain_driver_and_runtime_core_restore_deferred_snapshots() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); reset_preload_readiness_for_tests(); + let key = "standalone-deferred"; + let chain_key = format!("rln:wasm:chain-sync:{key}"); + let core_key = format!("rln:wasm:ln-runtime-core:{key}:committed"); + let chain = serde_json::json!({"schema_version":1,"network":"regtest","indexer_url":null, + "running":false,"poll_interval_ms":1000,"latest_tip_height":42,"last_tip_at":null, + "last_tick_at":null,"last_error":null,"rebroadcast_queue":[]}) + .to_string(); + let core = serde_json::json!({"revision":1,"schema_version":1,"lifecycle_state":"stopped", + "storage_initialized":true,"queued_events":[{"seq":7,"event_kind":"saved","payload_hex":"00","received_at":1}], + "next_event_seq":8}).to_string(); + local_storage_set_item(&chain_key, "invalid older cache").unwrap(); + local_storage_set_item(&core_key, "invalid older cache").unwrap(); finish_preload( - parse_indexed_db_snapshot(&snapshot_value(&[&neighbor])), + entries(&[(&chain_key, &chain), (&core_key, &core)]), RUNTIME_STATE_HYDRATE_PREFIXES, - false, 0, |_, _| Ok(()), ); - check_mainnet_runtime_state(&keys).unwrap(); - let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); - storage - .set_item(&protected, "new local-only state") + let driver = crate::chain_sync::WasmChainSyncDriver::new_without_resume( + key.into(), + "regtest".into(), + ) + .unwrap(); + assert_eq!(driver.latest_tip_height(), Some(42)); + let restored = crate::NativeLnRuntimeCore::new(key.into()); + assert_eq!(restored.status().queued_events, 1); + assert_eq!(restored.status().lifecycle_state, "stopped"); + assert_eq!( + local_storage_get_item(&chain_key).unwrap().as_deref(), + Some(chain.as_str()) + ); + local_storage_remove_item(&chain_key).unwrap(); + local_storage_remove_item(&core_key).unwrap(); + } + #[wasm_bindgen_test(async)] + async fn cold_supported_handles_restore_deferred_views_when_each_activates() { + crate::test_utils::reset_wasm_runtime_state_for_tests(); + reset_preload_readiness_for_tests(); + let proxy = "ws://deferred-node-views.invalid"; + let id = "supported"; + let keys = crate::wasm_node_persistence::RuntimeScopeKeys::from_runtime_scope_key(format!( + "{proxy}#runtime:{id}" + )); + let events = serde_json::json!({"events":[{"seq":7,"source":"saved","event_kind":"saved", + "payload_hex":"00","payment_hash":null,"status":null,"applied":true,"error":null,"received_at":1}],"next_seq":8}).to_string(); + let transfers = serde_json::json!({"transfers":[{"payment_hash":"saved-payment","inbound":true, + "asset_id":"saved-asset","asset_amount":3,"status":"succeeded","created_at":1,"updated_at":2}]}).to_string(); + local_storage_set_item(&keys.runtime_events_storage_key, "invalid old cache").unwrap(); + local_storage_set_item(&keys.rgb_ln_transfers_storage_key, "invalid old cache").unwrap(); + finish_preload( + entries(&[ + (&keys.runtime_events_storage_key, &events), + (&keys.rgb_ln_transfers_storage_key, &transfers), + ]), + RUNTIME_STATE_HYDRATE_PREFIXES, + 0, + |_, _| Ok(()), + ); + let first = + crate::RlnWasmNode::new_with_runtime_id_opt(proxy.into(), Some(id.into()), None) + .unwrap(); + let second = + crate::RlnWasmNode::new_with_runtime_id_opt(proxy.into(), Some(id.into()), None) + .unwrap(); + assert_eq!(first.list_runtime_events_json().unwrap(), "[]"); + assert_eq!( + local_storage_get_item(&keys.runtime_events_storage_key) + .unwrap() + .as_deref(), + Some("invalid old cache") + ); + let wallet = crate::RlnWasmWallet::create(&crate::test_utils::test_wallet_data_json()) + .await .unwrap(); - assert!(mainnet_error(&keys).contains("protected browser runtime state")); + let before = crate::ln_node::test_utils::startup_calls(); + first.attach_wallet(&wallet).unwrap(); + let passive_events: serde_json::Value = + serde_json::from_str(&second.list_runtime_events_json().unwrap()).unwrap(); + assert_eq!(passive_events[0]["seq"], 7); + assert_eq!( + first.list_runtime_events_json().unwrap(), + second.list_runtime_events_json().unwrap() + ); + assert_eq!(crate::ln_node::test_utils::startup_calls(), before); + let restored: serde_json::Value = + serde_json::from_str(&second.list_rgb_ln_transfers_json().unwrap()).unwrap(); + assert_eq!(restored[0]["payment_hash"], "saved-payment"); + let logs: serde_json::Value = + serde_json::from_str(&second.list_runtime_events_json().unwrap()).unwrap(); + assert_eq!(logs[0]["seq"], 7); + assert_eq!( + first.list_rgb_ln_transfers_json().unwrap(), + second.list_rgb_ln_transfers_json().unwrap() + ); assert_eq!( - storage.get_item(&protected).unwrap().as_deref(), - Some("new local-only state") + first.list_runtime_events_json().unwrap(), + second.list_runtime_events_json().unwrap() ); - storage.remove_item(&protected).unwrap(); + drop(first); + drop(second); + local_storage_remove_item(&keys.runtime_events_storage_key).unwrap(); + local_storage_remove_item(&keys.rgb_ln_transfers_storage_key).unwrap(); } }