diff --git a/lightning-invoice/Cargo.toml b/lightning-invoice/Cargo.toml index 7ec904be2..f5226f492 100644 --- a/lightning-invoice/Cargo.toml +++ b/lightning-invoice/Cargo.toml @@ -24,7 +24,7 @@ serde = { version = "1.0", optional = true, default-features = false, features = bitcoin = { version = "0.32.2", default-features = false, features = ["secp-recovery"] } # RGB and related -rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", tag = "v0.3.0-beta.34", default-features = false } +rgb-lib = { git = "https://github.com/Jainakin/rgb-lib.git", rev = "22c76737894db67caa2b0743e4c258ba8c2422f0", default-features = false } [dev-dependencies] serde_json = { version = "1"} diff --git a/lightning/Cargo.toml b/lightning/Cargo.toml index 39703d760..29eac03e0 100644 --- a/lightning/Cargo.toml +++ b/lightning/Cargo.toml @@ -59,7 +59,7 @@ amplify = "4.8" bincode = "1.3" rgb-strict-encoding = "1.0.1" futures = "0.3" -rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", tag = "v0.3.0-beta.34", default-features = false } +rgb-lib = { git = "https://github.com/Jainakin/rgb-lib.git", rev = "22c76737894db67caa2b0743e4c258ba8c2422f0", default-features = false } serde = { version = "^1.0", features = [ "derive", ] } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index acb49e598..23ce8dd41 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -119,7 +119,10 @@ use crate::onion_message::messenger::{ MessageRouter, MessageSendInstructions, Responder, ResponseInstruction, }; use crate::onion_message::offers::{OffersMessage, OffersMessageHandler}; -use crate::rgb_utils::{handle_funding, is_channel_rgb, RgbKvStoreExt}; +use crate::rgb_utils::{ + abort_prepared_funding, is_channel_rgb, prepare_funding, promote_funding, rollback_funding, + RgbKvStoreExt, +}; use crate::routing::router::{ BlindedTail, FixedRouter, InFlightHtlcs, Path, Payee, PaymentParameters, Route, RouteParameters, RouteParametersConfig, Router, @@ -207,6 +210,50 @@ use crate::ln::script::ShutdownScript; #[cfg(any(test, feature = "_rln_test_hooks"))] pub static DROP_FUNDING_SIGNED_ON_NODE: Mutex> = Mutex::new(None); +#[cfg(all(feature = "_rln_test_hooks", feature = "std"))] +fn pause_at_rgb_funding_checkpoint( + checkpoint_address_env: &str, temporary_channel_id: &ChannelId, funding_txid: &bitcoin::Txid, +) { + use std::io::Write; + use std::net::TcpStream; + + let Ok(checkpoint_address) = std::env::var(checkpoint_address_env) else { return }; + + let mut stream = TcpStream::connect(&checkpoint_address) + .expect("test checkpoint listener must accept the RGB funding notification"); + writeln!(stream, "{} {funding_txid}", temporary_channel_id) + .expect("test checkpoint notification must be written"); + stream.flush().expect("test checkpoint notification must be flushed"); + + // The controller normally terminates the process after this notification. A release byte keeps + // cleanup deterministic when a test fails before issuing the kill. + let mut release = [0_u8; 1]; + std::io::Read::read_exact(&mut stream, &mut release) + .expect("test checkpoint must be released or the process terminated"); +} + +#[cfg(all(feature = "_rln_test_hooks", feature = "std"))] +fn pause_after_rgb_funding_preparation( + temporary_channel_id: &ChannelId, funding_txid: &bitcoin::Txid, +) { + pause_at_rgb_funding_checkpoint( + "RLN_TEST_RGB_FUNDING_PREPARED_CHECKPOINT", + temporary_channel_id, + funding_txid, + ); +} + +#[cfg(all(feature = "_rln_test_hooks", feature = "std"))] +fn pause_after_rgb_funding_promotion( + temporary_channel_id: &ChannelId, funding_txid: &bitcoin::Txid, +) { + pause_at_rgb_funding_checkpoint( + "RLN_TEST_RGB_FUNDING_PROMOTED_CHECKPOINT", + temporary_channel_id, + funding_txid, + ); +} + // We hold various information about HTLC relay in the HTLC objects in Channel itself: // // Upon receipt of an HTLC from a peer, we'll give it a PendingHTLCStatus indicating if it should @@ -2879,7 +2926,6 @@ pub struct ChannelManager< per_peer_state: FairRwLock>>>, #[cfg(any(test, feature = "_test_utils"))] pub(super) per_peer_state: FairRwLock>>>, - /// We only support using one of [`ChannelMonitorUpdateStatus::InProgress`] and /// [`ChannelMonitorUpdateStatus::Completed`] without restarting. Because the API does not /// otherwise directly enforce this, we enforce it in non-test builds here by storing which one @@ -4051,7 +4097,6 @@ where highest_seen_timestamp: AtomicUsize::new(current_timestamp as usize), per_peer_state: FairRwLock::new(new_hash_map()), - #[cfg(not(any(test, feature = "_externalize_tests")))] monitor_update_type: AtomicUsize::new(0), @@ -4272,6 +4317,14 @@ where res } + /// Gets channels which have completed the funding handshake, in random order. + /// + /// Unlike [`Self::list_channels`], this excludes channels which have only negotiated or been + /// assigned a funding outpoint but have not reached funded channel state. + pub fn list_funded_channels(&self) -> Vec { + self.list_funded_channels_with_filter(|_| true) + } + /// Gets the list of open channels, in random order. See [`ChannelDetails`] field documentation for /// more information. pub fn list_channels(&self) -> Vec { @@ -10619,57 +10672,124 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ #[rustfmt::skip] fn internal_funding_created(&self, counterparty_node_id: &PublicKey, msg: &msgs::FundingCreated) -> Result<(), MsgHandleErrInternal> { let best_block = *self.best_block.read().unwrap(); - - let per_peer_state = self.per_peer_state.read().unwrap(); - let peer_state_mutex = per_peer_state.get(counterparty_node_id) - .ok_or_else(|| { - debug_assert!(false); - MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.temporary_channel_id) - })?; - - let mut peer_state_lock = peer_state_mutex.lock().unwrap(); - let peer_state = &mut *peer_state_lock; - let (mut chan, funding_msg_opt, monitor) = + let inbound_chan = { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id) + .ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::send_err_msg_no_close(format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"), msg.temporary_channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; match peer_state.channel_by_id.remove(&msg.temporary_channel_id) .map(Channel::into_unfunded_inbound_v1) { - Some(Ok(inbound_chan)) => { - let logger = WithChannelContext::from(&self.logger, &inbound_chan.context, None); - if inbound_chan.funding.is_colored() { - match handle_funding(&msg.temporary_channel_id, msg.funding_txid.to_string(), &self.ldk_data_dir, inbound_chan.funding.push_asset_amount(), self.rgb_kv_store.as_ref()) { - Ok(()) => (), - Err(e) => { - // at this point the channel initiator already transitioned its channel to the funded channel ID - let mut chan = Channel::from(inbound_chan); - let funding_txo = OutPoint { txid: msg.funding_txid, index: msg.funding_output_index }; - chan.context_mut().channel_id = ChannelId::v1_from_funding_outpoint(funding_txo); - return Err(convert_channel_err!(self, peer_state, e, &mut chan).1); - }, - } - } - match inbound_chan.funding_created(msg, best_block, &self.signer_provider, &&logger) { - Ok(res) => res, - Err((inbound_chan, err)) => { - // We've already removed this inbound channel from the map in `PeerState` - // above so at this point we just need to clean up any lingering entries - // concerning this channel as it is safe to do so. - debug_assert!(matches!(err, ChannelError::Close(_))); - let mut chan = Channel::from(inbound_chan); - return Err(convert_channel_err!(self, peer_state, err, &mut chan).1); - }, - } - }, + Some(Ok(inbound_chan)) => inbound_chan, Some(Err(mut chan)) => { - let err_msg = format!("Got an unexpected funding_created message from peer with counterparty_node_id {}", counterparty_node_id); - let err = ChannelError::close(err_msg); + let err = ChannelError::close(format!("Got an unexpected funding_created message from peer with counterparty_node_id {}", counterparty_node_id)); return Err(convert_channel_err!(self, peer_state, err, &mut chan).1); }, - None => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.temporary_channel_id)) - }; + None => return Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.temporary_channel_id)), + } + }; + + // RGB validation may perform substantial network work. Keep it outside the ChannelManager's + // peer-state mutex so local state queries are not blocked. PeerManager serializes this callback + // against connection lifecycle changes for the same peer. + let funding_acceptance_result = if inbound_chan.funding.is_colored() { + Some(prepare_funding( + &msg.temporary_channel_id, + msg.funding_txid.to_string(), + &self.ldk_data_dir, + msg.funding_output_index, + counterparty_node_id, + inbound_chan.funding.push_asset_amount(), + self.rgb_kv_store.as_ref(), + )) + } else { + None + }; + #[cfg(all(feature = "_rln_test_hooks", feature = "std"))] + if matches!(funding_acceptance_result.as_ref(), Some(Ok(_))) { + pause_after_rgb_funding_preparation(&msg.temporary_channel_id, &msg.funding_txid); + } + + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = match per_peer_state.get(counterparty_node_id) { + Some(peer_state) => peer_state, + None => { + if let Some(Ok(acceptance)) = funding_acceptance_result { + abort_prepared_funding(acceptance, self.rgb_kv_store.as_ref()).map_err(|error| { + MsgHandleErrInternal::send_err_msg_no_close( + format!("Peer disappeared and prepared RGB funding could not be rolled back: {error:?}"), + msg.temporary_channel_id, + ) + })?; + } + return Err(MsgHandleErrInternal::send_err_msg_no_close( + format!("Peer disconnected during funding acceptance: {counterparty_node_id}"), + msg.temporary_channel_id, + )); + }, + }; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + let prepared_acceptance = match funding_acceptance_result { + Some(Ok(acceptance)) => Some(acceptance), + Some(Err(error)) => { + let mut chan = Channel::from(inbound_chan); + let funding_txo = OutPoint { txid: msg.funding_txid, index: msg.funding_output_index }; + chan.context_mut().channel_id = ChannelId::v1_from_funding_outpoint(funding_txo); + return Err(convert_channel_err!(self, peer_state, error, &mut chan).1); + }, + None => None, + }; + + let promoted_acceptance_key = if let Some(acceptance) = prepared_acceptance { + match promote_funding(acceptance, self.rgb_kv_store.as_ref()) { + Ok(key) => { + #[cfg(all(feature = "_rln_test_hooks", feature = "std"))] + pause_after_rgb_funding_promotion(&msg.temporary_channel_id, &msg.funding_txid); + Some(key) + }, + Err(error) => { + let mut channel = Channel::from(inbound_chan); + let funding_txo = OutPoint { txid: msg.funding_txid, index: msg.funding_output_index }; + channel.context_mut().channel_id = ChannelId::v1_from_funding_outpoint(funding_txo); + return Err(convert_channel_err!(self, peer_state, error, &mut channel).1); + }, + } + } else { + None + }; + + let logger = WithChannelContext::from(&self.logger, &inbound_chan.context, None); + let (mut chan, funding_msg_opt, monitor) = match inbound_chan.funding_created(msg, best_block, &self.signer_provider, &&logger) { + Ok(res) => res, + Err((inbound_chan, err)) => { + if let Some(temporary_channel_id) = promoted_acceptance_key.as_ref() { + rollback_funding(temporary_channel_id, &self.ldk_data_dir, self.rgb_kv_store.as_ref()) + .map_err(|error| MsgHandleErrInternal::send_err_msg_no_close( + format!("Failed to roll back rejected RGB funding: {error:?}"), + msg.temporary_channel_id, + ))?; + } + debug_assert!(matches!(err, ChannelError::Close(_))); + let mut chan = Channel::from(inbound_chan); + return Err(convert_channel_err!(self, peer_state, err, &mut chan).1); + }, + }; let funded_channel_id = chan.context.channel_id(); macro_rules! fail_chan { ($err: expr) => { { + if let Some(temporary_channel_id) = promoted_acceptance_key.as_ref() { + rollback_funding(temporary_channel_id, &self.ldk_data_dir, self.rgb_kv_store.as_ref()) + .map_err(|error| MsgHandleErrInternal::send_err_msg_no_close( + format!("Failed to roll back invalid RGB funding: {error:?}"), + msg.temporary_channel_id, + ))?; + } // Note that at this point we've filled in the funding outpoint on our // channel, but its actually in conflict with another channel. Thus, if // we call `convert_channel_err` immediately (thus calling @@ -10681,10 +10801,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ return Err(convert_channel_err!(self, peer_state, err, &mut chan, UNFUNDED_CHANNEL).1); } } } + if peer_state.channel_by_id.contains_key(&funded_channel_id) { + fail_chan!("Already had channel with the new channel_id"); + } + match peer_state.channel_by_id.entry(funded_channel_id) { - hash_map::Entry::Occupied(_) => { - fail_chan!("Already had channel with the new channel_id"); - }, + hash_map::Entry::Occupied(_) => unreachable!("channel ID was checked immediately above"), hash_map::Entry::Vacant(e) => { let monitor_res = self.chain_monitor.watch_channel(monitor.channel_id(), monitor); if let Ok(persist_state) = monitor_res { @@ -18514,7 +18636,6 @@ where highest_seen_timestamp: AtomicUsize::new(highest_seen_timestamp as usize), per_peer_state: FairRwLock::new(per_peer_state), - #[cfg(not(any(test, feature = "_externalize_tests")))] monitor_update_type: AtomicUsize::new(0), diff --git a/lightning/src/rgb_utils/mod.rs b/lightning/src/rgb_utils/mod.rs index 4f95ad4df..d085a2544 100644 --- a/lightning/src/rgb_utils/mod.rs +++ b/lightning/src/rgb_utils/mod.rs @@ -5,6 +5,7 @@ #[cfg(not(any(feature = "electrum", feature = "esplora")))] compile_error!("at least one of the `electrum` and `esplora` features needs to be enabled"); +use crate::chain::transaction::OutPoint; use crate::ln::chan_utils::{ get_countersigner_payment_script, BuiltCommitmentTransaction, ClosingTransaction, CommitmentTransaction, HTLCOutputInCommitment, @@ -27,11 +28,13 @@ use rgb_lib::{ bitcoin::psbt::Psbt as RgbLibPsbt, keys::WitnessVersion, wallet::{ - rust_only::{AssetColoringInfo, ColoringInfo}, + rust_only::{ + AssetColoringInfo, ColoringInfo, PreparedRgbTransferAcceptance, RgbAcceptanceResolution, + }, DatabaseType, OnlineOptions, RgbWalletOpsOffline, SinglesigKeys, Wallet, WalletData, }, AssetSchema, Assignment, BitcoinNetwork, ConsignmentExt, ContractId, Error as RgbLibError, - Fascia, FileContent, RgbTransfer, WitnessOrd, + Fascia, FileContent, WitnessOrd, }; use serde::{Deserialize, Serialize}; use strict_encoding::{StrictDeserialize, StrictSerialize}; @@ -68,6 +71,8 @@ pub const RGB_PRIMARY_NS: &str = "rgb"; pub const RGB_CHANNEL_INFO_NS: &str = "channel_info"; /// Secondary namespace for pending channel info pub const RGB_CHANNEL_INFO_PENDING_NS: &str = "channel_info_pending"; +/// Secondary namespace for durable inbound RGB funding acceptance state. +pub const RGB_FUNDING_ACCEPTANCE_NS: &str = "funding_acceptance"; /// Secondary namespace for inbound payment info pub const RGB_PAYMENT_INFO_INBOUND_NS: &str = "payment_info_inbound"; /// Secondary namespace for outbound payment info @@ -83,7 +88,7 @@ pub const RGB_WALLET_CONFIG_NS: &str = "wallet_config"; const VANILLA_SYNC_LOOKBACK: u32 = 20; /// RGB channel info -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] pub struct RgbInfo { /// Channel contract ID #[serde(with = "contract_id_serde")] @@ -131,6 +136,118 @@ pub struct TransferInfo { pub output_map: HashMap, } +/// Durable phase of an inbound RGB channel funding acceptance. +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +pub enum FundingAcceptanceStage { + /// The RGB consignment is being fetched and validated outside the peer mutex. + Validating, + /// The validated RGB result is durable in an isolated staging stock. + Prepared, + /// The staged stock is live but retains an exact rollback snapshot. + Promoted, + /// A rollback decision was persisted before restoring the prior stock. + RollingBack, + /// The embedding node observed durable funded channel state and committed the RGB result. + Finalized, + /// Acceptance failed and the funding handshake must be retried from a fresh channel. + RetryRequired, + /// The embedding node persisted a commit decision and is applying it to the RGB stock. + Finalizing, +} + +/// Crash evidence for an inbound RGB funding operation. +/// +/// The embedding node reconciles this record against durable LDK channel state before deciding +/// whether to commit, roll back, or quarantine an ambiguous promoted funding attempt. +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct PendingFundingAcceptance { + /// Persistence schema version. + pub version: u8, + /// Temporary channel ID used as the durable record key. + pub temporary_channel_id: String, + /// Remote node participating in the funding handshake. + pub counterparty_node_id: String, + /// Proposed funding transaction ID. + pub funding_txid: String, + /// Funding output index in the proposed transaction. + pub funding_output_index: u16, + /// Asset amount pushed to the accepting node. + pub push_asset_amount: Option, + /// Current durable acceptance phase. + pub stage: FundingAcceptanceStage, + /// Validated transfer consignment, populated once preparation completes. + pub consignment: Option>, + /// Derived channel metadata, populated once preparation completes. + pub rgb_info: Option, +} + +impl PendingFundingAcceptance { + const VERSION: u8 = 3; + + /// Returns the stable persistence key. + pub fn key(&self) -> &str { + &self.temporary_channel_id + } + + fn validate(&self) -> Result<(), io::Error> { + let is_fixed_hex = |value: &str, byte_len: usize| { + value.len() == byte_len * 2 + && value.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit()) + }; + if self.version != Self::VERSION + || !is_fixed_hex(&self.temporary_channel_id, 32) + || !is_fixed_hex(&self.counterparty_node_id, 33) + || !is_fixed_hex(&self.funding_txid, 32) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid RGB funding acceptance journal", + )); + } + Ok(()) + } +} + +/// Atomically writes the current funding acceptance record. +pub fn write_pending_funding_acceptance( + record: &PendingFundingAcceptance, kv_store: &dyn KVStoreSync, +) -> Result<(), io::Error> { + record.validate()?; + let data = + bincode::serialize(record).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + kv_store.write(RGB_PRIMARY_NS, RGB_FUNDING_ACCEPTANCE_NS, record.key(), data) +} + +/// Reads a funding acceptance record by temporary channel ID. +pub fn read_pending_funding_acceptance( + temporary_channel_id: &str, kv_store: &dyn KVStoreSync, +) -> Result { + let data = kv_store.read(RGB_PRIMARY_NS, RGB_FUNDING_ACCEPTANCE_NS, temporary_channel_id)?; + let record: PendingFundingAcceptance = + bincode::deserialize(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + record.validate()?; + if record.key() != temporary_channel_id { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "RGB funding acceptance journal key mismatch", + )); + } + Ok(record) +} + +/// Durably removes a completed funding acceptance record. +pub fn remove_pending_funding_acceptance( + temporary_channel_id: &str, kv_store: &dyn KVStoreSync, +) -> Result<(), io::Error> { + kv_store.remove(RGB_PRIMARY_NS, RGB_FUNDING_ACCEPTANCE_NS, temporary_channel_id, false) +} + +/// A validated RGB funding acceptance whose live wallet state is still unchanged. +pub(crate) struct PreparedFundingAcceptance { + prepared: PreparedRgbTransferAcceptance, + record: PendingFundingAcceptance, +} + mod contract_id_serde { use super::*; use serde::{Deserializer, Serializer}; @@ -234,7 +351,9 @@ fn _get_wallet_data( ) } -async fn _get_rgb_wallet(ldk_data_dir: &Path, kv_store: &dyn KVStoreSync) -> Wallet { +async fn _get_rgb_wallet( + ldk_data_dir: &Path, kv_store: &dyn KVStoreSync, +) -> Result { let ( data_dir, bitcoin_network, @@ -254,7 +373,9 @@ async fn _get_rgb_wallet(ldk_data_dir: &Path, kv_store: &dyn KVStoreSync) -> Wal ) }) .await - .unwrap() + .map_err(|error| { + ChannelError::close(format!("RGB wallet worker failed before completion: {error}")) + }) } pub(crate) fn is_asset_known( @@ -262,14 +383,16 @@ pub(crate) fn is_asset_known( ) -> bool { let handle = Handle::current(); let _ = handle.enter(); - let wallet = futures::executor::block_on(_get_rgb_wallet(ldk_data_dir, kv_store)); + let Ok(wallet) = futures::executor::block_on(_get_rgb_wallet(ldk_data_dir, kv_store)) else { + return false; + }; wallet.is_asset_known(contract_id).unwrap_or(false) } -async fn _accept_transfer( - ldk_data_dir: &Path, funding_txid: String, kv_store: &dyn KVStoreSync, -) -> Result<(RgbTransfer, Vec, HashSet, PathBuf), RgbLibError> { - let funding_vout = 1; +async fn _prepare_transfer_acceptance( + operation_id: String, ldk_data_dir: &Path, funding_txid: String, funding_vout: u32, + kv_store: &dyn KVStoreSync, +) -> Result<(PreparedRgbTransferAcceptance, PathBuf), RgbLibError> { let ( data_dir, bitcoin_network, @@ -290,22 +413,28 @@ async fn _accept_transfer( master_fingerprint, reuse_addresses, ); - let online = wallet.go_online(OnlineOptions { + wallet.go_online(OnlineOptions { indexer_url, skip_consistency_check: true, vanilla_sync_lookback: VANILLA_SYNC_LOOKBACK, })?; - let (consignment, assignments, media_digests) = wallet.accept_transfer_consignment( - online, - consignment_path, - funding_txid.clone(), + let consignment_bytes = + fs::read(&consignment_path).map_err(|_| RgbLibError::InvalidFilePath { + file_path: consignment_path.to_string_lossy().into_owned(), + })?; + let prepared = wallet.prepare_accept_transfer_from_consignment( + operation_id, + funding_txid, funding_vout, + consignment_bytes, STATIC_BLINDING, )?; - Ok((consignment, assignments, media_digests, wallet.get_media_dir())) + Ok((prepared, wallet.get_media_dir())) }) .await - .unwrap() + .map_err(|error| RgbLibError::Internal { + details: format!("RGB funding worker failed before completion: {error}"), + })? } fn _counterparty_output_index( @@ -521,24 +650,40 @@ where static_blinding: Some(STATIC_BLINDING), nonce: None, }; - let psbt = Psbt::from_unsigned_tx(commitment_tx.clone()).unwrap(); - let mut psbt = RgbLibPsbt::from_str(&psbt.to_string()).unwrap(); + let operation_id = funding_scope + .get_funding_txo() + .ok_or_else(|| { + ChannelError::close("RGB commitment is missing its funding outpoint".to_owned()) + })? + .txid + .to_string(); + let mut psbt = RgbLibPsbt::from_unsigned_tx(commitment_tx.clone()).map_err(|error| { + ChannelError::close(format!("Failed to construct RGB commitment PSBT: {error}")) + })?; let handle = Handle::current(); let _ = handle.enter(); - let wallet = futures::executor::block_on(_get_rgb_wallet(ldk_data_dir, kv_store)); - let (fascia, _) = wallet.color_psbt(&mut psbt, coloring_info).unwrap(); - let psbt = Psbt::from_str(&psbt.to_string()).unwrap(); + let wallet = futures::executor::block_on(_get_rgb_wallet(ldk_data_dir, kv_store))?; + let fascia = wallet + .color_psbt_and_consume_for_operation( + &operation_id, + &mut psbt, + coloring_info, + Some(WitnessOrd::Ignored), + ) + .map_err(funding_acceptance_error)?; let modified_tx = match psbt.extract_tx() { Ok(tx) => tx, Err(ExtractTxError::MissingInputValue { tx }) => tx, - Err(e) => panic!("should never happen: {e}"), + Err(error) => { + return Err(ChannelError::close(format!( + "Failed to extract colored RGB commitment transaction: {error}" + ))) + }, }; let txid = modified_tx.compute_txid(); commitment_transaction.built = BuiltCommitmentTransaction { transaction: modified_tx, txid }; - wallet.consume_fascia(fascia.clone(), Some(WitnessOrd::Ignored)).unwrap(); - // Keep the latest fascia per commitment side so a wallet restored without // an RGB backup can re-consume it and color force-close sweeps. let fascia_key = format!("{}_{}", chan_id, if counterparty { "cp" } else { "local" }); @@ -547,7 +692,6 @@ where kv_store .write(RGB_PRIMARY_NS, RGB_COMMITMENT_FASCIA_NS, &fascia_key, fascia_bytes) .expect("KVStore write failed"); - let transfer_info = TransferInfo { contract_id, output_map }; kv_store.write_rgb_transfer_info(&txid.to_string(), &transfer_info); @@ -584,7 +728,7 @@ pub(crate) fn color_htlc( let mut psbt = RgbLibPsbt::from_str(&psbt.to_string()).unwrap(); let handle = Handle::current(); let _ = handle.enter(); - let wallet = futures::executor::block_on(_get_rgb_wallet(ldk_data_dir, kv_store)); + let wallet = futures::executor::block_on(_get_rgb_wallet(ldk_data_dir, kv_store))?; let (fascia, _) = wallet.color_psbt(&mut psbt, coloring_info).unwrap(); let psbt = Psbt::from_str(&psbt.to_string()).unwrap(); let modified_tx = match psbt.extract_tx() { @@ -648,7 +792,7 @@ pub(crate) fn color_closing( let mut psbt = RgbLibPsbt::from_str(&psbt.to_string()).unwrap(); let handle = Handle::current(); let _ = handle.enter(); - let wallet = futures::executor::block_on(_get_rgb_wallet(ldk_data_dir, kv_store)); + let wallet = futures::executor::block_on(_get_rgb_wallet(ldk_data_dir, kv_store))?; let (fascia, _) = wallet.color_psbt(&mut psbt, coloring_info).unwrap(); let psbt = Psbt::from_str(&psbt.to_string()).unwrap(); let modified_tx = match psbt.extract_tx() { @@ -710,130 +854,400 @@ pub fn write_rgb_payment_info_file( .expect("able to write rgb payment info pending"); } -/// Rename RGB channel info from temporary to final channel ID in KVStore -pub(crate) fn rename_rgb_files( +/// Renames RGB channel state from a temporary to a final channel ID. +pub(crate) fn try_rename_rgb_files( channel_id: &ChannelId, temporary_channel_id: &ChannelId, kv_store: &dyn KVStoreSync, -) { +) -> Result<(), io::Error> { let temp_chan_id = temporary_channel_id.0.as_hex().to_string(); let chan_id = channel_id.0.as_hex().to_string(); - let rgb_info = kv_store.read_rgb_channel_info(&temp_chan_id, false).expect("rename ok"); - kv_store.write_rgb_channel_info(&chan_id, &rgb_info, false); - kv_store.remove_rgb_channel_info(&temp_chan_id, false).expect("rename ok"); - - let rgb_info = kv_store.read_rgb_channel_info(&temp_chan_id, true).expect("rename ok"); - kv_store.write_rgb_channel_info(&chan_id, &rgb_info, true); - kv_store.remove_rgb_channel_info(&temp_chan_id, true).expect("rename ok"); + for namespace in [RGB_CHANNEL_INFO_NS, RGB_CHANNEL_INFO_PENDING_NS] { + match kv_store.read(RGB_PRIMARY_NS, namespace, &temp_chan_id) { + Ok(data) => { + bincode::deserialize::(&data) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + match kv_store.read(RGB_PRIMARY_NS, namespace, &chan_id) { + Ok(existing) if existing != data => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "final RGB channel metadata differs from temporary metadata", + )); + }, + Ok(_) => {}, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + kv_store.write(RGB_PRIMARY_NS, namespace, &chan_id, data)?; + }, + Err(error) => return Err(error), + } + match kv_store.remove(RGB_PRIMARY_NS, namespace, &temp_chan_id, false) { + Ok(()) => {}, + Err(error) if error.kind() == io::ErrorKind::NotFound => {}, + Err(error) => return Err(error), + } + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let data = kv_store.read(RGB_PRIMARY_NS, namespace, &chan_id)?; + bincode::deserialize::(&data) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + }, + Err(error) => return Err(error), + } + } - if let Ok(consignment_data) = kv_store.read_rgb_consignment(&temp_chan_id) { - kv_store.write_rgb_consignment(&chan_id, consignment_data); - kv_store.remove_rgb_consignment(&temp_chan_id); + match kv_store.read(RGB_PRIMARY_NS, RGB_CONSIGNMENT_NS, &temp_chan_id) { + Ok(data) => { + match kv_store.read(RGB_PRIMARY_NS, RGB_CONSIGNMENT_NS, &chan_id) { + Ok(existing) if existing != data => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "final RGB consignment differs from temporary consignment", + )); + }, + Ok(_) => {}, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + kv_store.write(RGB_PRIMARY_NS, RGB_CONSIGNMENT_NS, &chan_id, data)?; + }, + Err(error) => return Err(error), + } + match kv_store.remove(RGB_PRIMARY_NS, RGB_CONSIGNMENT_NS, &temp_chan_id, false) { + Ok(()) => {}, + Err(error) if error.kind() == io::ErrorKind::NotFound => {}, + Err(error) => return Err(error), + } + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => {}, + Err(error) => return Err(error), } + // The funding-acceptance journal intentionally remains keyed by the temporary channel ID. + // RLN removes it only after both the finalized RGB wallet and canonical channel metadata have + // been acknowledged by VSS. Deleting it here would make a crash between ChannelMonitor + // persistence and RLN event handling unrecoverable on a fresh device. + Ok(()) } -/// Directory holding the media received for a funding, before the contract has vouched for it. -pub fn get_media_staging_dir(ldk_data_dir: &Path, funding_txid: &str) -> PathBuf { - ldk_data_dir.join(format!("media_staging_{funding_txid}")) +/// Renames RGB channel state while preserving the legacy fail-stop behavior. +pub(crate) fn rename_rgb_files( + channel_id: &ChannelId, temporary_channel_id: &ChannelId, kv_store: &dyn KVStoreSync, +) { + try_rename_rgb_files(channel_id, temporary_channel_id, kv_store) + .expect("RGB channel ID transition must persist"); } -/// Handle funding on the receiver side -pub(crate) fn handle_funding( - temporary_channel_id: &ChannelId, funding_txid: String, ldk_data_dir: &Path, - push_asset_amount: Option, kv_store: &dyn KVStoreSync, -) -> Result<(), ChannelError> { - let handle = Handle::current(); - let _ = handle.enter(); - let accept_res = - futures::executor::block_on(_accept_transfer(ldk_data_dir, funding_txid.clone(), kv_store)); - let (consignment, remote_rgb_assignments, media_digests, media_dir) = match accept_res { - Ok(res) => res, - Err(RgbLibError::InvalidConsignment) => { - return Err(ChannelError::close("Invalid RGB consignment for funding".to_owned())) +fn funding_acceptance_error(error: RgbLibError) -> ChannelError { + match error { + RgbLibError::InvalidConsignment => { + ChannelError::close("Invalid RGB consignment for funding".to_owned()) }, - Err(RgbLibError::NoConsignment) => { - return Err(ChannelError::close("Failed to find RGB consignment".to_owned())) + RgbLibError::NoConsignment => { + ChannelError::close("Failed to find RGB consignment".to_owned()) }, - Err(RgbLibError::UnknownRgbSchema { schema_id }) => { - return Err(ChannelError::close(format!("Unknown RGB schema: {schema_id}"))) + RgbLibError::UnknownRgbSchema { schema_id } => { + ChannelError::close(format!("Unknown RGB schema: {schema_id}")) }, - Err(RgbLibError::UnsupportedSchema { asset_schema }) => { - return Err(ChannelError::close(format!("Unsupported RGB schema: {asset_schema}"))) + RgbLibError::UnsupportedSchema { asset_schema } => { + ChannelError::close(format!("Unsupported RGB schema: {asset_schema}")) }, - Err(RgbLibError::Indexer { details }) - | Err(RgbLibError::InvalidIndexer { details }) - | Err(RgbLibError::Network { details }) => { - return Err(ChannelError::close(format!("Failed to connect to indexer: {details}"))) + RgbLibError::Indexer { details } + | RgbLibError::InvalidIndexer { details } + | RgbLibError::Network { details } => { + ChannelError::close(format!("Failed to connect to indexer: {details}")) }, - Err(e) => return Err(ChannelError::close(format!("Unexpected error: {e}"))), - }; + error => ChannelError::close(format!("Unexpected RGB funding error: {error}")), + } +} - // Validate before persisting anything: an invalid consignment must not leave orphaned - // media/consignment behind in the wallet dirs. - if remote_rgb_assignments.len() != 1 { - return Err(ChannelError::close(format!( - "Unexpected number of RGB assignments: {}", - remote_rgb_assignments.len() - ))); +/// Directory holding the media received for a funding, before the contract has vouched for it. +pub fn get_media_staging_dir(ldk_data_dir: &Path, funding_txid: &str) -> PathBuf { + ldk_data_dir.join(format!("media_staging_{funding_txid}")) +} + +fn funding_storage_error(context: &str, error: impl core::fmt::Display) -> ChannelError { + ChannelError::close(format!("{context}: {error}")) +} + +fn persist_retry_required( + record: &mut PendingFundingAcceptance, kv_store: &dyn KVStoreSync, context: &str, +) -> Result<(), ChannelError> { + record.stage = FundingAcceptanceStage::RetryRequired; + // Retry evidence only needs the operation identity and handshake inputs. Retaining a complete + // validated consignment after rollback would grow storage with the asset's full history and keep + // data which no longer participates in recovery. + record.consignment = None; + record.rgb_info = None; + write_pending_funding_acceptance(record, kv_store) + .map_err(|error| funding_storage_error(context, error)) +} + +fn persist_prepared_funding_artifacts( + record: &PendingFundingAcceptance, kv_store: &dyn KVStoreSync, +) -> Result<(), ChannelError> { + let consignment = record.consignment.as_ref().ok_or_else(|| { + ChannelError::close("Prepared RGB funding is missing its consignment".to_owned()) + })?; + let rgb_info = record.rgb_info.as_ref().ok_or_else(|| { + ChannelError::close("Prepared RGB funding is missing channel metadata".to_owned()) + })?; + let serialized_info = bincode::serialize(rgb_info).map_err(|error| { + funding_storage_error("Failed to serialize RGB channel metadata", error) + })?; + for namespace in [RGB_CHANNEL_INFO_PENDING_NS, RGB_CHANNEL_INFO_NS] { + kv_store + .write(RGB_PRIMARY_NS, namespace, &record.temporary_channel_id, serialized_info.clone()) + .map_err(|error| { + funding_storage_error("Failed to stage RGB channel metadata", error) + })?; } - let channel_rgb_amount = match remote_rgb_assignments[0] { - Assignment::Fungible(amt) => amt, - Assignment::NonFungible => 1, - _ => unreachable!("unsupported schema"), - }; - let push_amount = push_asset_amount.unwrap_or(0); - let remote_rgb_amount = channel_rgb_amount.checked_sub(push_amount).ok_or_else(|| { - ChannelError::close(format!( - "push_asset_amount {push_amount} exceeds channel asset amount {channel_rgb_amount}" - )) + for key in [&record.funding_txid, &record.temporary_channel_id] { + kv_store + .write(RGB_PRIMARY_NS, RGB_CONSIGNMENT_NS, key, consignment.clone()) + .map_err(|error| funding_storage_error("Failed to stage RGB consignment", error))?; + } + Ok(()) +} + +fn remove_if_present( + kv_store: &dyn KVStoreSync, secondary_namespace: &str, key: &str, +) -> Result<(), ChannelError> { + match kv_store.remove(RGB_PRIMARY_NS, secondary_namespace, key, false) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(funding_storage_error("Failed to remove staged RGB funding data", error)), + } +} + +fn clear_prepared_funding_artifacts( + record: &PendingFundingAcceptance, kv_store: &dyn KVStoreSync, +) -> Result<(), ChannelError> { + let funding_txid = bitcoin::Txid::from_str(&record.funding_txid).map_err(|error| { + ChannelError::close(format!("Invalid funding transaction ID in RGB journal: {error}")) })?; + let final_channel_id = ChannelId::v1_from_funding_outpoint(OutPoint { + txid: funding_txid, + index: record.funding_output_index, + }); + let final_channel_id = final_channel_id.0.as_hex().to_string(); + for namespace in [RGB_CHANNEL_INFO_PENDING_NS, RGB_CHANNEL_INFO_NS] { + remove_if_present(kv_store, namespace, &record.temporary_channel_id)?; + remove_if_present(kv_store, namespace, &final_channel_id)?; + } + for key in [&record.funding_txid, &record.temporary_channel_id, &final_channel_id] { + remove_if_present(kv_store, RGB_CONSIGNMENT_NS, key)?; + } + Ok(()) +} - let mut consignment_buf = Vec::new(); - consignment.save(&mut consignment_buf).expect("unable to serialize consignment"); - kv_store.write_rgb_consignment(&funding_txid, consignment_buf.clone()); - let temp_chan_id = temporary_channel_id.0.as_hex().to_string(); - kv_store.write_rgb_consignment(&temp_chan_id, consignment_buf); +fn abort_prepared_with_record( + prepared: PreparedRgbTransferAcceptance, record: &mut PendingFundingAcceptance, + kv_store: &dyn KVStoreSync, +) -> Result<(), ChannelError> { + record.stage = FundingAcceptanceStage::RollingBack; + write_pending_funding_acceptance(record, kv_store) + .map_err(|error| funding_storage_error("Failed to persist RGB rollback decision", error))?; + prepared.abort().map_err(funding_acceptance_error)?; + clear_prepared_funding_artifacts(record, kv_store)?; + persist_retry_required(record, kv_store, "Failed to persist RGB retry decision") +} - let staging_dir = get_media_staging_dir(ldk_data_dir, &funding_txid); +fn install_funding_media( + media_digests: &HashSet, media_dir: &Path, ldk_data_dir: &Path, funding_txid: &str, +) -> Result<(), ChannelError> { + let staging_dir = get_media_staging_dir(ldk_data_dir, funding_txid); + let mut pending_moves = Vec::new(); for digest in media_digests { - let media_path = media_dir.join(&digest); + let media_path = media_dir.join(digest); if media_path.exists() { continue; } - let staged_path = staging_dir.join(&digest); - let Ok(media_bytes) = fs::read(&staged_path) else { - return Err(ChannelError::close(format!( - "Missing RGB media file {digest} for funding" - ))); - }; - if sha256::Hash::hash(&media_bytes).to_string() != digest { + let staged_path = staging_dir.join(digest); + let media_bytes = fs::read(&staged_path).map_err(|_| { + ChannelError::close(format!("Missing RGB media file {digest} for funding")) + })?; + if sha256::Hash::hash(&media_bytes).to_string() != *digest { return Err(ChannelError::close(format!( "Corrupt RGB media file {digest} for funding" ))); } - if let Err(e) = fs::rename(&staged_path, &media_path) { + pending_moves.push((staged_path, media_path)); + } + for (staged_path, media_path) in pending_moves { + fs::rename(&staged_path, &media_path).map_err(|error| { + ChannelError::close(format!( + "Failed to store RGB media file {} for funding: {error}", + staged_path.display() + )) + })?; + } + let _ = fs::remove_dir_all(staging_dir); + Ok(()) +} + +/// Validates RGB funding into an isolated stock without changing the live wallet. +pub(crate) fn prepare_funding( + temporary_channel_id: &ChannelId, funding_txid: String, ldk_data_dir: &Path, + funding_output_index: u16, counterparty_node_id: &PublicKey, push_asset_amount: Option, + kv_store: &dyn KVStoreSync, +) -> Result { + let temporary_channel_id = temporary_channel_id.0.as_hex().to_string(); + let mut record = PendingFundingAcceptance { + version: PendingFundingAcceptance::VERSION, + temporary_channel_id, + counterparty_node_id: counterparty_node_id.to_string(), + funding_txid: funding_txid.clone(), + funding_output_index, + push_asset_amount, + stage: FundingAcceptanceStage::Validating, + consignment: None, + rgb_info: None, + }; + write_pending_funding_acceptance(&record, kv_store) + .map_err(|error| funding_storage_error("Failed to persist RGB funding intent", error))?; + + let handle = Handle::current(); + let _runtime_guard = handle.enter(); + let (prepared, media_dir) = match futures::executor::block_on(_prepare_transfer_acceptance( + funding_txid.clone(), + ldk_data_dir, + funding_txid.clone(), + funding_output_index as u32, + kv_store, + )) { + Ok(prepared) => prepared, + Err(error) => { + persist_retry_required( + &mut record, + kv_store, + "Failed to persist rejected RGB funding validation", + )?; + return Err(funding_acceptance_error(error)); + }, + }; + + let prepared_data = (|| { + let mut consignment_buf = Vec::new(); + prepared + .consignment() + .save(&mut consignment_buf) + .map_err(|error| funding_storage_error("Failed to serialize RGB consignment", error))?; + if prepared.assignments().len() != 1 { return Err(ChannelError::close(format!( - "Failed to store RGB media file {digest} for funding: {e}" + "Unexpected number of RGB assignments: {}", + prepared.assignments().len() ))); } - } - // on the error paths above the staging directory is left for the file transfer handler's sweep - let _ = fs::remove_dir_all(&staging_dir); - - let rgb_info = RgbInfo { - contract_id: consignment.contract_id(), - schema: AssetSchema::from_schema_id(consignment.schema_id()).unwrap(), - local_rgb_amount: push_amount, - remote_rgb_amount, - batch_transfer_idx: None, - // only meaningful on the initiator side, which is the one that sends media - counterparty_knows_asset: false, + let channel_rgb_amount = match prepared.assignments()[0] { + Assignment::Fungible(amount) => amount, + Assignment::NonFungible => 1, + _ => unreachable!("unsupported schema"), + }; + let push_amount = push_asset_amount.unwrap_or(0); + let remote_rgb_amount = channel_rgb_amount.checked_sub(push_amount).ok_or_else(|| { + ChannelError::close(format!( + "RGB push amount {push_amount} exceeds received channel amount {channel_rgb_amount}" + )) + })?; + let schema = + AssetSchema::from_schema_id(prepared.consignment().schema_id()).map_err(|error| { + ChannelError::close(format!("Unsupported RGB funding schema: {error}")) + })?; + install_funding_media(prepared.media_digests(), &media_dir, ldk_data_dir, &funding_txid)?; + Ok(( + consignment_buf, + RgbInfo { + contract_id: prepared.consignment().contract_id(), + schema, + local_rgb_amount: push_amount, + remote_rgb_amount, + batch_transfer_idx: None, + counterparty_knows_asset: false, + }, + )) + })(); + let (consignment_buf, rgb_info) = match prepared_data { + Ok(data) => data, + Err(error) => { + abort_prepared_with_record(prepared, &mut record, kv_store)?; + return Err(error); + }, }; - let temporary_channel_id_str = temporary_channel_id.0.as_hex().to_string(); + record.stage = FundingAcceptanceStage::Prepared; + record.consignment = Some(consignment_buf); + record.rgb_info = Some(rgb_info); + if let Err(error) = write_pending_funding_acceptance(&record, kv_store) + .map_err(|error| funding_storage_error("Failed to persist prepared RGB funding", error)) + .and_then(|_| persist_prepared_funding_artifacts(&record, kv_store)) + { + abort_prepared_with_record(prepared, &mut record, kv_store)?; + return Err(error); + } + Ok(PreparedFundingAcceptance { prepared, record }) +} - kv_store.write_rgb_channel_info(&temporary_channel_id_str, &rgb_info, true); - kv_store.write_rgb_channel_info(&temporary_channel_id_str, &rgb_info, false); +/// Promotes a prepared RGB stock while retaining its rollback snapshot. +pub(crate) fn promote_funding( + acceptance: PreparedFundingAcceptance, kv_store: &dyn KVStoreSync, +) -> Result { + let PreparedFundingAcceptance { prepared, mut record } = acceptance; + let promoted = prepared.promote().map_err(funding_acceptance_error)?; + record.stage = FundingAcceptanceStage::Promoted; + if let Err(error) = write_pending_funding_acceptance(&record, kv_store) + .map_err(|error| funding_storage_error("Failed to persist promoted RGB funding", error)) + { + promoted.resolve(RgbAcceptanceResolution::Rollback).map_err(funding_acceptance_error)?; + clear_prepared_funding_artifacts(&record, kv_store)?; + persist_retry_required( + &mut record, + kv_store, + "Failed to persist RGB funding retry after promotion rollback", + )?; + return Err(error); + } + Ok(record.temporary_channel_id) +} - Ok(()) +/// Discards a prepared acceptance after durably recording the retry decision. +pub(crate) fn abort_prepared_funding( + acceptance: PreparedFundingAcceptance, kv_store: &dyn KVStoreSync, +) -> Result<(), ChannelError> { + let PreparedFundingAcceptance { prepared, mut record } = acceptance; + abort_prepared_with_record(prepared, &mut record, kv_store) +} + +fn wallet_for_rgb_resolution(ldk_data_dir: &Path, kv_store: &dyn KVStoreSync) -> Wallet { + let ( + data_dir, + bitcoin_network, + account_xpub_vanilla, + account_xpub_colored, + master_fingerprint, + reuse_addresses, + ) = _get_wallet_data(ldk_data_dir, kv_store); + _new_rgb_wallet( + data_dir, + bitcoin_network, + account_xpub_vanilla, + account_xpub_colored, + master_fingerprint, + reuse_addresses, + ) +} + +/// Rolls back a funding acceptance before `funding_signed` can be released. +pub(crate) fn rollback_funding( + temporary_channel_id: &str, ldk_data_dir: &Path, kv_store: &dyn KVStoreSync, +) -> Result<(), ChannelError> { + let mut record = read_pending_funding_acceptance(temporary_channel_id, kv_store) + .map_err(|error| funding_storage_error("Failed to load RGB funding rollback", error))?; + record.stage = FundingAcceptanceStage::RollingBack; + write_pending_funding_acceptance(&record, kv_store) + .map_err(|error| funding_storage_error("Failed to persist RGB rollback decision", error))?; + let wallet = wallet_for_rgb_resolution(ldk_data_dir, kv_store); + if wallet.pending_rgb_acceptance().map_err(funding_acceptance_error)?.is_some() { + wallet + .resolve_pending_rgb_acceptance(&record.funding_txid, RgbAcceptanceResolution::Rollback) + .map_err(funding_acceptance_error)?; + } + clear_prepared_funding_artifacts(&record, kv_store)?; + persist_retry_required(&mut record, kv_store, "Failed to persist RGB retry decision") } pub(crate) fn set_counterparty_knows_asset(channel_id: &ChannelId, kv_store: &dyn KVStoreSync) { @@ -1022,3 +1436,90 @@ pub fn holder_validate_install_psbt_output_witness_scripts_hex(hex_scripts: Vec< pub fn holder_validate_take_psbt_output_witness_scripts_hex() -> Option> { HOLDER_VALIDATE_PSBT_WITNESS_SCRIPTS_HEX.with(|c| c.borrow_mut().take()) } + +#[cfg(test)] +mod funding_acceptance_tests { + use super::*; + use crate::util::test_utils::TestStore; + + fn record(stage: FundingAcceptanceStage) -> PendingFundingAcceptance { + PendingFundingAcceptance { + version: PendingFundingAcceptance::VERSION, + temporary_channel_id: "01".repeat(32), + counterparty_node_id: "02".repeat(33), + funding_txid: "03".repeat(32), + funding_output_index: 0, + push_asset_amount: Some(500), + stage, + consignment: None, + rgb_info: None, + } + } + + #[test] + fn pending_funding_state_survives_reopen() { + let store = TestStore::new(false); + let validating = record(FundingAcceptanceStage::Validating); + write_pending_funding_acceptance(&validating, &store).unwrap(); + + let after_reopen = read_pending_funding_acceptance(validating.key(), &store).unwrap(); + assert_eq!(after_reopen, validating); + + remove_pending_funding_acceptance(validating.key(), &store).unwrap(); + assert!(read_pending_funding_acceptance(validating.key(), &store).is_err()); + } + + #[test] + fn pending_funding_state_rejects_malformed_identity() { + let store = TestStore::new(false); + let mut malformed = record(FundingAcceptanceStage::Validating); + malformed.funding_txid = "zz".repeat(32); + assert!(write_pending_funding_acceptance(&malformed, &store).is_err()); + } + + #[test] + fn channel_rename_retains_promoted_funding_journal_for_application_reconciliation() { + let store = TestStore::new(false); + let temporary_channel_id = ChannelId::from_bytes([1; 32]); + let channel_id = ChannelId::from_bytes([2; 32]); + let temporary_channel_id_hex = temporary_channel_id.0.as_hex().to_string(); + let info = RgbInfo { + contract_id: ContractId::from_str( + "rgb:Ar4ouaLv-b7f7Dc_-z5EMvtu-FA5KNh1-nlae~jk-8xMBo7E", + ) + .unwrap(), + schema: AssetSchema::Nia, + local_rgb_amount: 500, + remote_rgb_amount: 0, + batch_transfer_idx: None, + counterparty_knows_asset: false, + }; + store.write_rgb_channel_info(&temporary_channel_id_hex, &info, false); + store.write_rgb_channel_info(&temporary_channel_id_hex, &info, true); + let mut promoted = record(FundingAcceptanceStage::Promoted); + promoted.temporary_channel_id = temporary_channel_id_hex.clone(); + write_pending_funding_acceptance(&promoted, &store).unwrap(); + + try_rename_rgb_files(&channel_id, &temporary_channel_id, &store).unwrap(); + + assert_eq!( + read_pending_funding_acceptance(&temporary_channel_id_hex, &store).unwrap(), + promoted, + ); + assert_eq!( + store.read_rgb_channel_info(&channel_id.0.as_hex().to_string(), false).unwrap(), + info, + ); + try_rename_rgb_files(&channel_id, &temporary_channel_id, &store).unwrap(); + } + + #[test] + fn channel_rename_reports_missing_metadata_without_panicking() { + let store = TestStore::new(false); + let temporary_channel_id = ChannelId::from_bytes([1; 32]); + let channel_id = ChannelId::from_bytes([2; 32]); + + let error = try_rename_rgb_files(&channel_id, &temporary_channel_id, &store).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } +}