From 4c4de0701d9e6cb0f1a0c5f7c52936cc63eac58d Mon Sep 17 00:00:00 2001 From: Andrea Ballarini Date: Fri, 16 Oct 2026 13:02:25 +0200 Subject: [PATCH] fix(rgb): color justice sweeps and both commitment views --- lightning/Cargo.toml | 1 + lightning/src/chain/channelmonitor.rs | 32 ++- lightning/src/chain/onchaintx.rs | 42 +++- lightning/src/chain/package.rs | 48 +++- lightning/src/ln/chanmon_update_fail_tests.rs | 4 +- lightning/src/ln/channel.rs | 34 ++- lightning/src/ln/functional_test_utils.rs | 4 +- lightning/src/ln/functional_tests.rs | 6 +- lightning/src/ln/reload_tests.rs | 4 +- lightning/src/rgb_utils/mod.rs | 238 ++++++++++++++++-- lightning/src/util/persist.rs | 24 +- lightning/src/util/test_utils.rs | 6 +- 12 files changed, 374 insertions(+), 69 deletions(-) diff --git a/lightning/Cargo.toml b/lightning/Cargo.toml index eeb54ddee..4b6c892cf 100644 --- a/lightning/Cargo.toml +++ b/lightning/Cargo.toml @@ -57,6 +57,7 @@ inventory = { version = "0.3", optional = true } # RGB and related amplify = "4.8" bincode = "1.3" +serde_json = "1.0" 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.50-bfa", default-features = false } diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index bf6e51325..878478a62 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -69,7 +69,7 @@ use crate::types::features::ChannelTypeFeatures; use crate::types::payment::{PaymentHash, PaymentPreimage}; use crate::util::byte_utils; use crate::util::logger::{Logger, WithContext}; -use crate::util::persist::MonitorName; +use crate::util::persist::{KVStoreSync, MonitorName}; use crate::util::ser::{ MaybeReadable, Readable, ReadableArgs, RequiredWrapper, UpgradableRequired, Writeable, Writer, U48, @@ -79,6 +79,7 @@ use crate::util::ser::{ use crate::prelude::*; use crate::io::{self, Error}; +use crate::sync::Arc; use crate::sync::Mutex; use core::ops::Deref; use core::{cmp, mem}; @@ -1872,7 +1873,8 @@ impl ChannelMonitor { commitment_transaction_number_obscure_factor: u64, initial_holder_commitment_tx: HolderCommitmentTransaction, best_block: BestBlock, counterparty_node_id: PublicKey, channel_id: ChannelId, - is_manual_broadcast: bool, + is_manual_broadcast: bool, ldk_data_dir: Option, + rgb_kv_store: Option>, ) -> ChannelMonitor { assert!(commitment_transaction_number_obscure_factor <= (1 << 48)); @@ -1895,6 +1897,7 @@ impl ChannelMonitor { let onchain_tx_handler = OnchainTxHandler::new( channel_parameters.channel_value_satoshis, channel_keys_id, destination_script.into(), keys, channel_parameters.clone(), initial_holder_commitment_tx.clone(), secp_ctx, + ldk_data_dir, rgb_kv_store, ); let funding_outpoint = channel_parameters.funding_outpoint @@ -6419,10 +6422,14 @@ where const MAX_ALLOC_SIZE: usize = 64 * 1024; -impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP)> +impl<'a, 'b, ES: EntropySource, SP: SignerProvider> + ReadableArgs<(&'a ES, &'b SP, Option, Option>)> for (BlockHash, ChannelMonitor) { - fn read(reader: &mut R, args: (&'a ES, &'b SP)) -> Result { + fn read( + reader: &mut R, + args: (&'a ES, &'b SP, Option, Option>), + ) -> Result { match >::read(reader, args) { Ok(Some(res)) => Ok(res), Ok(None) => Err(DecodeError::UnknownRequiredFeature), @@ -6431,11 +6438,12 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP } } -impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP)> +impl<'a, 'b, ES: EntropySource, SP: SignerProvider> + ReadableArgs<(&'a ES, &'b SP, Option, Option>)> for Option<(BlockHash, ChannelMonitor)> { #[rustfmt::skip] - fn read(reader: &mut R, args: (&'a ES, &'b SP)) -> Result { + fn read(reader: &mut R, args: (&'a ES, &'b SP, Option, Option>)) -> Result { macro_rules! unwrap_obj { ($key: expr) => { match $key { @@ -6445,7 +6453,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP } } - let (entropy_source, signer_provider) = args; + let (entropy_source, signer_provider, ldk_data_dir_arg, rgb_kv_store_arg) = args; let _ver = read_ver_prefix!(reader, SERIALIZATION_VERSION); @@ -6620,7 +6628,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP } } let onchain_tx_handler: OnchainTxHandler = ReadableArgs::read( - reader, (entropy_source, signer_provider, channel_value_satoshis, channel_keys_id) + reader, (entropy_source, signer_provider, channel_value_satoshis, channel_keys_id, ldk_data_dir_arg, rgb_kv_store_arg) )?; let lockdown_from_offchain = Readable::read(reader)?; @@ -6966,8 +6974,8 @@ mod tests { &[(0, broadcast_tx)], conf_height); let (_, pre_update_monitor) = <(BlockHash, ChannelMonitor<_>)>::read( - &mut io::Cursor::new(&get_monitor!(nodes[1], channel.2).encode()), - (&nodes[1].keys_manager.backing, &nodes[1].keys_manager.backing)).unwrap(); + &mut io::Cursor::new(&get_monitor!(nodes[1], channel.2).encode()), + (&nodes[1].keys_manager.backing, &nodes[1].keys_manager.backing, None)).unwrap(); // If the ChannelManager tries to update the channel, however, the ChainMonitor will pass // the update through to the ChannelMonitor which will refuse it (as the channel is closed). @@ -7121,7 +7129,7 @@ mod tests { let monitor = ChannelMonitor::new( Secp256k1::new(), keys, Some(shutdown_script.into_inner()), 0, &ScriptBuf::new(), &channel_parameters, true, 0, HolderCommitmentTransaction::dummy(0, funding_outpoint, Vec::new()), - best_block, dummy_key, channel_id, false, + best_block, dummy_key, channel_id, false, None, None, ); let nondust_htlcs = preimages_slice_to_htlcs!(preimages[0..10]); @@ -7382,7 +7390,7 @@ mod tests { let monitor = ChannelMonitor::new( Secp256k1::new(), keys, Some(shutdown_script.into_inner()), 0, &ScriptBuf::new(), &channel_parameters, true, 0, HolderCommitmentTransaction::dummy(0, funding_outpoint, Vec::new()), - best_block, dummy_key, channel_id, false, + best_block, dummy_key, channel_id, false, None, None, ); let chan_id = monitor.inner.lock().unwrap().channel_id(); diff --git a/lightning/src/chain/onchaintx.rs b/lightning/src/chain/onchaintx.rs index fb65aa0f1..f02f02dbf 100644 --- a/lightning/src/chain/onchaintx.rs +++ b/lightning/src/chain/onchaintx.rs @@ -34,7 +34,9 @@ use crate::ln::chan_utils::{ }; use crate::ln::msgs::DecodeError; use crate::sign::{ecdsa::EcdsaChannelSigner, EntropySource, HTLCDescriptor, SignerProvider}; +use crate::sync::Arc; use crate::util::logger::Logger; +use crate::util::persist::KVStoreSync; use crate::util::ser::{ MaybeReadable, Readable, ReadableArgs, UpgradableRequired, Writeable, Writer, }; @@ -277,6 +279,11 @@ pub struct OnchainTxHandler { onchain_events_awaiting_threshold_conf: Vec, pub(super) secp_ctx: Secp256k1, + + /// Runtime `ldk_data_dir` + RGB KVStore used by `color_claim` to reach RGB transfer + /// info. `None` means no RGB coloring should be attempted. + ldk_data_dir: Option, + rgb_kv_store: Option>, } impl PartialEq for OnchainTxHandler { @@ -348,15 +355,20 @@ impl OnchainTxHandler { } } -impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP, u64, [u8; 32])> - for OnchainTxHandler +impl<'a, 'b, ES: EntropySource, SP: SignerProvider> + ReadableArgs<( + &'a ES, + &'b SP, + u64, + [u8; 32], + Option, + Option>, + )> for OnchainTxHandler { #[rustfmt::skip] - fn read(reader: &mut R, args: (&'a ES, &'b SP, u64, [u8; 32])) -> Result { - let entropy_source = args.0; - let signer_provider = args.1; - let channel_value_satoshis = args.2; - let channel_keys_id = args.3; + fn read(reader: &mut R, args: (&'a ES, &'b SP, u64, [u8; 32], Option, Option>)) -> Result { + let (entropy_source, signer_provider, channel_value_satoshis, channel_keys_id, ldk_data_dir_arg, rgb_kv_store_arg) = + args; let _ver = read_ver_prefix!(reader, SERIALIZATION_VERSION); @@ -438,6 +450,8 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP onchain_events_awaiting_threshold_conf, pending_claim_events: Vec::new(), secp_ctx, + ldk_data_dir: ldk_data_dir_arg, + rgb_kv_store: rgb_kv_store_arg, }) } } @@ -447,6 +461,7 @@ impl OnchainTxHandler { channel_value_satoshis: u64, channel_keys_id: [u8; 32], destination_script: ScriptBuf, signer: ChannelSigner, channel_parameters: ChannelTransactionParameters, holder_commitment: HolderCommitmentTransaction, secp_ctx: Secp256k1, + ldk_data_dir: Option, rgb_kv_store: Option>, ) -> Self { OnchainTxHandler { channel_value_satoshis, @@ -462,6 +477,8 @@ impl OnchainTxHandler { onchain_events_awaiting_threshold_conf: Vec::new(), pending_claim_events: Vec::new(), secp_ctx, + ldk_data_dir, + rgb_kv_store, } } @@ -479,6 +496,16 @@ impl OnchainTxHandler { events } + /// Returns the runtime `ldk_data_dir` used by `color_claim`. + pub(crate) fn ldk_data_dir(&self) -> Option<&str> { + self.ldk_data_dir.as_deref() + } + + /// Returns the runtime RGB KVStore used by `color_claim`. + pub(crate) fn rgb_kv_store(&self) -> Option<&Arc> { + self.rgb_kv_store.as_ref() + } + /// Triggers rebroadcasts/fee-bumps of pending claims from a force-closed channel. This is /// crucial in preventing certain classes of pinning attacks, detecting substantial mempool /// feerate changes between blocks, and ensuring reliability if broadcasting fails. We recommend @@ -1372,6 +1399,7 @@ mod tests { chan_params, holder_commit, secp_ctx, + None, ); // Create a broadcaster with current block height 1. diff --git a/lightning/src/chain/package.rs b/lightning/src/chain/package.rs index db46f3be6..96bb1e3d6 100644 --- a/lightning/src/chain/package.rs +++ b/lightning/src/chain/package.rs @@ -37,6 +37,7 @@ use crate::ln::chan_utils::{ use crate::ln::channel_keys::{DelayedPaymentBasepoint, HtlcBasepoint}; use crate::ln::channelmanager::MIN_CLTV_EXPIRY_DELTA; use crate::ln::msgs::DecodeError; +use crate::rgb_utils::color_claim; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::{ChannelDerivationParameters, HTLCDescriptor}; use crate::types::features::ChannelTypeFeatures; @@ -47,6 +48,7 @@ use crate::util::ser::{Readable, ReadableArgs, RequiredWrapper, Writeable, Write use crate::io; use core::cmp; use core::ops::Deref; +use std::path::Path; #[allow(unused_imports)] use crate::prelude::*; @@ -116,6 +118,10 @@ pub(crate) fn verify_channel_type_features(channel_type_features: &Option bool { + !self.inputs.is_empty() + && self.inputs.iter().all(|(_, d)| { + matches!( + d, + PackageSolvingData::RevokedOutput(..) + | PackageSolvingData::RevokedHTLCOutput(..) + ) + }) + } pub(crate) fn package_weight(&self, destination_script: &Script) -> u64 { let mut inputs_weight = 0; let mut witnesses_weight = 2; // count segwit flags @@ -1366,7 +1384,12 @@ impl PackageTemplate { // version: 4 bytes ; count_tx_in: 1 byte ; count_tx_out: 1 byte ; lock_time: 4 bytes let transaction_weight = 10 * WITNESS_SCALE_FACTOR; // value: 8 bytes ; var_int: 1 byte ; pk_script: `destination_script.len()` - let output_weight = (8 + 1 + destination_script.len()) * WITNESS_SCALE_FACTOR; + let mut output_weight = (8 + 1 + destination_script.len()) * WITNESS_SCALE_FACTOR; + // Justice (penalty) transactions get an RGB OP_RETURN output appended by `color_claim`. + // Account for its weight here so the feerate computation and downstream assert are accurate. + if self.is_justice_package() { + output_weight += RGB_OPRETURN_OUTPUT_WEIGHT as usize; + } (inputs_weight + witnesses_weight + transaction_weight + output_weight) as u64 } #[rustfmt::skip] @@ -1409,6 +1432,21 @@ impl PackageTemplate { for (outpoint, outp) in self.inputs.iter() { bumped_tx.input.push(outp.as_tx_input(*outpoint)); } + // Only color justice (penalty) claims: all inputs must be revoked counterparty outputs, + // possibly spending more than one revoked transaction (e.g. a revoked commitment and + // HTLC transactions spending it). `color_claim` groups the inputs by spent transaction + // and loads the matching `{txid}_transfer_info` for each. Coloring needs to happen + // before `finalize_input` signs over all outputs so that the RGB OP_RETURN is part of + // what gets signed. + if self.is_justice_package() { + if let Some(ldk_data_dir) = onchain_handler.ldk_data_dir() { + if let Some(kv_store) = onchain_handler.rgb_kv_store() { + let _ = color_claim( + &mut bumped_tx, Path::new(ldk_data_dir), kv_store.as_ref(), logger, + ); + } + } + } for (i, (outpoint, out)) in self.inputs.iter().enumerate() { log_debug!(logger, "Adding claiming input for outpoint {}:{}", outpoint.txid, outpoint.vout); if !out.finalize_input(&mut bumped_tx, i, onchain_handler) { continue; } @@ -1776,7 +1814,8 @@ mod tests { use crate::chain::package::{ feerate_bump, weight_offered_htlc, weight_received_htlc, CounterpartyOfferedHTLCOutput, CounterpartyReceivedHTLCOutput, HolderFundingOutput, HolderHTLCOutput, PackageSolvingData, - PackageTemplate, RevokedHTLCOutput, RevokedOutput, WEIGHT_REVOKED_OUTPUT, + PackageTemplate, RevokedHTLCOutput, RevokedOutput, RGB_OPRETURN_OUTPUT_WEIGHT, + WEIGHT_REVOKED_OUTPUT, }; use crate::chain::Txid; use crate::ln::chan_utils::{ @@ -2187,7 +2226,10 @@ mod tests { { let revk_outp = dumb_revk_output!(); let package = PackageTemplate::build_package(fake_txid(1), 0, revk_outp, 0); - assert_eq!(package.package_weight(&ScriptBuf::new()), weight_sans_output + WEIGHT_REVOKED_OUTPUT); + assert_eq!( + package.package_weight(&ScriptBuf::new()), + weight_sans_output + WEIGHT_REVOKED_OUTPUT + RGB_OPRETURN_OUTPUT_WEIGHT + ); } { diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 958667aab..f7fbe4dd5 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -89,7 +89,7 @@ fn test_monitor_and_persister_update_fail() { let monitor = nodes[0].chain_monitor.chain_monitor.get_monitor(chan.2).unwrap(); let (_, new_monitor) = <(BlockHash, ChannelMonitor)>::read( &mut &monitor.encode()[..], - (nodes[0].keys_manager, nodes[0].keys_manager), + (nodes[0].keys_manager, nodes[0].keys_manager, None, None), ) .unwrap(); assert!(new_monitor == *monitor); @@ -4919,6 +4919,8 @@ fn native_async_persist() { Arc::clone(&keys_manager), Arc::clone(&tx_broadcaster), Arc::clone(&fee_estimator), + None, + None, ); let chain_source = test_utils::TestChainSource::new(Network::Testnet); let async_chain_monitor = ChainMonitor::new_async_beta( diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 8d2a2ba53..1373a240b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3267,7 +3267,7 @@ where holder_commitment_point.next_transaction_number(), &holder_commitment_point.next_point(), true, false, logger); if self.context().is_colored() { - color_commitment(&self.context(), &self.funding(), &mut commitment_data.tx, false).expect("successful commitment coloring"); + color_commitment(&self.context(), &self.funding(), &mut commitment_data.tx, false)?; } let initial_commitment_tx = commitment_data.tx; let trusted_tx = initial_commitment_tx.trust(); @@ -3311,7 +3311,7 @@ where context.counterparty_next_commitment_transaction_number, &context.counterparty_next_commitment_point.unwrap(), false, false, logger); if self.context().is_colored() { - color_commitment(&self.context(), &self.funding(), &mut commitment_data.tx, true).unwrap(); + color_commitment(&self.context(), &self.funding(), &mut commitment_data.tx, true)?; } let counterparty_initial_commitment_tx = commitment_data.tx; let counterparty_trusted_tx = counterparty_initial_commitment_tx.trust(); @@ -3381,6 +3381,8 @@ where &funding.channel_transaction_parameters, funding.is_outbound(), obscure_factor, holder_commitment_tx, best_block, context.counterparty_node_id, context.channel_id(), context.is_manual_broadcast, + Some(context.ldk_data_dir.to_string_lossy().into_owned()), + Some(Arc::clone(&context.rgb_kv_store)), ); channel_monitor.provide_initial_counterparty_commitment_tx( counterparty_initial_commitment_tx.clone(), @@ -5168,8 +5170,7 @@ where logger, ); if self.is_colored() { - color_commitment(&self, &funding, &mut commitment_data.tx, false) - .expect("successful commitment coloring"); + color_commitment(&self, &funding, &mut commitment_data.tx, false)?; } let commitment_txid = { let trusted_tx = commitment_data.tx.trust(); @@ -6496,8 +6497,10 @@ where logger, ); if self.is_colored() { - color_commitment(&self, &funding, &mut commitment_data.tx, true) - .expect("successful commitment coloring"); + if let Err(e) = color_commitment(&self, &funding, &mut commitment_data.tx, true) { + log_error!(logger, "RGB commitment coloring failed, aborting: {}", e); + return None; + } } let counterparty_initial_commitment_tx = commitment_data.tx; match self.holder_signer { @@ -7481,8 +7484,7 @@ where &mut closing_transaction, &self.context.ldk_data_dir, self.context.rgb_kv_store.as_ref(), - ) - .expect("successful closing TX coloring"); + )?; } Ok((closing_transaction, total_fee_satoshis)) } @@ -8213,8 +8215,7 @@ where &pending_splice_funding, &mut counterparty_commitment_tx, true, - ) - .expect("successful commitment coloring"); + )?; } { @@ -9813,7 +9814,9 @@ where self.context.counterparty_next_commitment_transaction_number + 1, &self.context.counterparty_next_commitment_point.unwrap(), false, false, logger); if self.context.is_colored() { - color_commitment(&self.context, &self.funding, &mut commitment_data.tx, true).expect("successful commitment coloring"); + if let Err(e) = color_commitment(&self.context, &self.funding, &mut commitment_data.tx, true) { + log_error!(logger, "RGB commitment coloring failed, skipping: {}", e); + } } let counterparty_initial_commitment_tx = commitment_data.tx; self.context.get_funding_signed_msg(&self.funding.channel_transaction_parameters, logger, counterparty_initial_commitment_tx) @@ -13136,7 +13139,9 @@ where &self.context.counterparty_next_commitment_point.unwrap(), false, true, logger, ); if self.context.is_colored() { - color_commitment(&self.context, &self.funding, &mut commitment_data.tx, true).expect("successful commitment coloring"); + if let Err(e) = color_commitment(&self.context, &self.funding, &mut commitment_data.tx, true) { + log_error!(logger, "RGB commitment coloring failed, skipping: {}", e); + } } let counterparty_commitment_tx = commitment_data.tx; @@ -13778,7 +13783,10 @@ where self.context.counterparty_next_commitment_transaction_number, &self.context.counterparty_next_commitment_point.unwrap(), false, false, logger); if self.context.is_colored() { - color_commitment(&self.context, &self.funding, &mut commitment_data.tx, true).unwrap(); + if let Err(e) = color_commitment(&self.context, &self.funding, &mut commitment_data.tx, true) { + log_error!(logger, "RGB commitment coloring failed, aborting: {}", e); + return None; + } } let counterparty_initial_commitment_tx = commitment_data.tx; let signature = match &self.context.holder_signer { diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 2786bca97..ae8ffc51f 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -850,7 +850,7 @@ impl<'a, 'b, 'c> Drop for Node<'a, 'b, 'c> { let (_, deserialized_monitor) = <(BlockHash, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), - (self.keys_manager, self.keys_manager), + (self.keys_manager, self.keys_manager, None, None), ) .unwrap(); deserialized_monitors.push(deserialized_monitor); @@ -1335,7 +1335,7 @@ pub fn _reload_node<'a, 'b, 'c>( let mut monitor_read = &encoded[..]; let (_, monitor) = <(BlockHash, ChannelMonitor)>::read( &mut monitor_read, - (node.keys_manager, node.keys_manager), + (node.keys_manager, node.keys_manager, None, None), ) .unwrap(); assert!(monitor_read.is_empty()); diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 58d851327..90c0df4e4 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -7242,7 +7242,7 @@ pub fn test_update_err_monitor_lockdown() { let new_monitor = <(BlockHash, channelmonitor::ChannelMonitor)>::read( &mut io::Cursor::new(&monitor.encode()), - (nodes[0].keys_manager, nodes[0].keys_manager), + (nodes[0].keys_manager, nodes[0].keys_manager, None, None), ) .unwrap() .1; @@ -7348,7 +7348,7 @@ pub fn test_concurrent_monitor_claim() { let new_monitor = <(BlockHash, channelmonitor::ChannelMonitor)>::read( &mut io::Cursor::new(&monitor.encode()), - (nodes[0].keys_manager, nodes[0].keys_manager), + (nodes[0].keys_manager, nodes[0].keys_manager, None, None), ) .unwrap() .1; @@ -7398,7 +7398,7 @@ pub fn test_concurrent_monitor_claim() { let new_monitor = <(BlockHash, channelmonitor::ChannelMonitor)>::read( &mut io::Cursor::new(&monitor.encode()), - (nodes[0].keys_manager, nodes[0].keys_manager), + (nodes[0].keys_manager, nodes[0].keys_manager, None, None), ) .unwrap() .1; diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 9e169d176..76731cc17 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -410,7 +410,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut node_0_stale_monitors = Vec::new(); for serialized in node_0_stale_monitors_serialized.iter() { let mut read = &serialized[..]; - let (_, monitor) = <(BlockHash, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); + let (_, monitor) = <(BlockHash, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager, None, None)).unwrap(); assert!(read.is_empty()); node_0_stale_monitors.push(monitor); } @@ -418,7 +418,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut node_0_monitors = Vec::new(); for serialized in node_0_monitors_serialized.iter() { let mut read = &serialized[..]; - let (_, monitor) = <(BlockHash, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager)).unwrap(); + let (_, monitor) = <(BlockHash, ChannelMonitor)>::read(&mut read, (keys_manager, keys_manager, None, None)).unwrap(); assert!(read.is_empty()); node_0_monitors.push(monitor); } diff --git a/lightning/src/rgb_utils/mod.rs b/lightning/src/rgb_utils/mod.rs index 7c3c6640d..45a64e805 100644 --- a/lightning/src/rgb_utils/mod.rs +++ b/lightning/src/rgb_utils/mod.rs @@ -15,6 +15,7 @@ use crate::ln::types::ChannelId; use crate::sign::SignerProvider; use crate::types::features::ChannelTypeFeatures; use crate::types::payment::PaymentHash; +use crate::util::logger::Logger; use crate::util::persist::KVStoreSync; use bitcoin::blockdata::transaction::Transaction; @@ -34,6 +35,7 @@ use rgb_lib::{ Fascia, FileContent, RgbTransfer, WitnessOrd, }; use serde::{Deserialize, Serialize}; +use serde_json; use strict_encoding::{StrictDeserialize, StrictSerialize}; use tokio::runtime::Handle; @@ -390,6 +392,18 @@ pub fn is_tx_colored(tx: &Transaction) -> bool { op_return_position(tx).is_some() } +/// Read TransferInfo file +pub fn read_rgb_transfer_info(path: &Path) -> TransferInfo { + let serialized_info = fs::read_to_string(path).expect("able to read transfer info file"); + serde_json::from_str(&serialized_info).expect("valid transfer info") +} + +/// Write TransferInfo file +pub fn write_rgb_transfer_info(path: &PathBuf, info: &TransferInfo) { + let serialized_info = serde_json::to_string(&info).expect("valid transfer info"); + fs::write(path, serialized_info).expect("able to write transfer info file") +} + /// Color commitment transaction pub(crate) fn color_commitment( channel_context: &ChannelContext, funding_scope: &FundingScope, @@ -564,13 +578,21 @@ where output_map.insert(vout_p2wsh as u32, vout_p2wsh_amt); } - let asset_coloring_info = AssetColoringInfo { - output_map: output_map.clone(), - static_blinding: Some(STATIC_BLINDING), - }; + // Both commitment views are colored on every node, so whichever commitment is + // broadcast both nodes hold its allocation: this covers force-close in both + // directions and penalties. The blinding is derived per commitment (from the + // uncolored txid, identically on both nodes): seals are witness-relative, so a + // shared blinding would make sibling transitions content-identical and let + // consignment generation pick the never-broadcast witness. + let blinding = u64::from_be_bytes( + commitment_tx.compute_txid().as_byte_array()[0..8].try_into().expect("fixed-size slice"), + ); + + let asset_coloring_info = + AssetColoringInfo { output_map: output_map.clone(), static_blinding: Some(blinding) }; let coloring_info = ColoringInfo { asset_info_map: HashMap::from_iter([(contract_id, asset_coloring_info)]), - static_blinding: Some(STATIC_BLINDING), + static_blinding: Some(blinding), nonce: None, }; let psbt = Psbt::from_unsigned_tx(commitment_tx.clone()).unwrap(); @@ -578,7 +600,9 @@ where 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 (fascia, _) = wallet + .color_psbt(&mut psbt, coloring_info) + .map_err(|e| ChannelError::close(format!("RGB commitment coloring failed: {e}")))?; let psbt = Psbt::from_str(&psbt.to_string()).unwrap(); let modified_tx = match psbt.extract_tx() { Ok(tx) => tx, @@ -589,19 +613,28 @@ where 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" }); - let fascia_bytes = - fascia.to_strict_serialized::<{ usize::MAX }>().expect("serializable fascia").release(); - kv_store - .write(RGB_PRIMARY_NS, RGB_COMMITMENT_FASCIA_NS, &fascia_key, fascia_bytes) - .expect("KVStore write failed"); - + // save RGB transfer data to disk let transfer_info = TransferInfo { contract_id, output_map }; - kv_store.write_rgb_transfer_info(&txid.to_string(), &transfer_info); + let transfer_info_path = ldk_data_dir.join(format!("{txid}_transfer_info")); + // Deterministic colored txid: an existing transfer info means the identical + // transition was already consumed (state rebuild); skip the duplicate consume. + if !transfer_info_path.exists() { + wallet + .consume_fascia(fascia.clone(), Some(WitnessOrd::Ignored)) + .map_err(|e| ChannelError::close(format!("RGB commitment consume failed: {e}")))?; + + // 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" }); + let fascia_bytes = + fascia.to_strict_serialized::<{ usize::MAX }>().expect("serializable fascia").release(); + kv_store + .write(RGB_PRIMARY_NS, RGB_COMMITMENT_FASCIA_NS, &fascia_key, fascia_bytes) + .expect("KVStore write failed"); + + kv_store.write_rgb_transfer_info(&txid.to_string(), &transfer_info); + write_rgb_transfer_info(&transfer_info_path, &transfer_info); + } Ok(()) } @@ -654,6 +687,167 @@ pub(crate) fn color_htlc( Ok(()) } +/// Leaves the justice sweep uncolored (burning its RGB) and surfaces why at error level. +fn uncolored_fallback( + logger: &dyn Logger, prev_txid: &str, reason: &str, +) -> Result<(), ChannelError> { + log_error!( + logger, + "Unable to color justice transaction for {prev_txid}: {reason} \ + (falling back to uncolored)", + ); + Ok(()) +} + +/// Colors a justice (penalty) transaction with the RGB of the revoked outputs it +/// spends. Inputs are grouped by spent txid and each `{txid}_transfer_info` is +/// consulted; inputs without info are logged and left out. With nothing loadable +/// or a zero total the transaction is left uncolored. +pub(crate) fn color_claim( + claim_tx: &mut Transaction, ldk_data_dir: &Path, kv_store: &dyn KVStoreSync, + logger: &dyn Logger, +) -> Result<(), ChannelError> { + let mut prev_txids: Vec = Vec::new(); + for input in &claim_tx.input { + let txid = input.previous_output.txid.to_string(); + if !prev_txids.contains(&txid) { + prev_txids.push(txid); + } + } + let mut transfer_infos: HashMap = HashMap::new(); + for prev_txid in &prev_txids { + let transfer_info_path = ldk_data_dir.join(format!("{prev_txid}_transfer_info")); + let outcome = fs::read_to_string(&transfer_info_path).map_err(|e| e.to_string()).and_then( + |serialized_info| { + serde_json::from_str::(&serialized_info) + .map_err(|e| format!("corrupt RGB transfer info for {prev_txid}: {e}")) + }, + ); + match outcome { + Ok(transfer_info) => { + transfer_infos.insert(prev_txid.clone(), transfer_info); + }, + Err(e) => { + log_error!( + logger, + "Unable to account RGB for justice inputs spending {prev_txid}: {e}" + ); + }, + } + } + if transfer_infos.is_empty() { + return uncolored_fallback(logger, &prev_txids.join(", "), "missing RGB transfer info"); + } + let contract_id = transfer_infos.values().next().expect("non-empty map").contract_id; + let prev_txid: &str = &prev_txids.join(", "); + let total_rgb: u64 = claim_tx + .input + .iter() + .filter_map(|input| { + let txid = input.previous_output.txid.to_string(); + transfer_infos + .get(&txid) + .and_then(|transfer_info| transfer_info.output_map.get(&input.previous_output.vout)) + }) + .sum(); + if total_rgb == 0 { + return uncolored_fallback(logger, prev_txid, "zero RGB assets in transfer info"); + } + + let output_map = HashMap::from([(0, total_rgb)]); + let asset_coloring_info = AssetColoringInfo { + output_map: output_map.clone(), + static_blinding: Some(STATIC_BLINDING), + }; + let coloring_info = ColoringInfo { + asset_info_map: HashMap::from_iter([(contract_id, asset_coloring_info)]), + static_blinding: Some(STATIC_BLINDING), + nonce: Some(1), + }; + + let psbt = match Psbt::from_unsigned_tx(claim_tx.clone()) { + Ok(p) => p, + Err(e) => { + return uncolored_fallback(logger, prev_txid, &format!("failed to build PSBT ({e})")) + }, + }; + let mut rgb_psbt = match RgbLibPsbt::from_str(&psbt.to_string()) { + Ok(p) => p, + Err(e) => { + return uncolored_fallback( + logger, + prev_txid, + &format!("failed to parse RGB PSBT ({e})"), + ) + }, + }; + let handle = Handle::current(); + let _ = handle.enter(); + let wallet = futures::executor::block_on(_get_rgb_wallet(ldk_data_dir, kv_store)); + let (fascia, _) = match wallet.color_psbt(&mut rgb_psbt, coloring_info) { + Ok(res) => res, + Err(e) => { + log_error!( + logger, + "RGB wallet failed to color justice transaction for {}: {e} \ + (falling back to uncolored)", + prev_txid, + ); + return Ok(()); + }, + }; + let psbt = match Psbt::from_str(&rgb_psbt.to_string()) { + Ok(p) => p, + Err(e) => { + return uncolored_fallback( + logger, + prev_txid, + &format!("failed to serialize colored PSBT ({e})"), + ) + }, + }; + let modified_tx = match psbt.extract_tx() { + Ok(tx) => tx, + Err(ExtractTxError::MissingInputValue { tx }) => tx, + Err(e) => { + return uncolored_fallback( + logger, + prev_txid, + &format!("failed to extract colored transaction ({e})"), + ) + }, + }; + let txid = &modified_tx.compute_txid(); + + if let Err(e) = wallet.consume_fascia(fascia.clone(), Some(WitnessOrd::Ignored)) { + log_error!( + logger, + "Failed to consume RGB fascia for justice transaction {txid}: {e} \ + (falling back to uncolored)", + ); + return Ok(()); + } + + let transfer_info = TransferInfo { contract_id, output_map }; + let transfer_info_path = ldk_data_dir.join(format!("{txid}_transfer_info")); + write_rgb_transfer_info(&transfer_info_path, &transfer_info); + // The daemon's sweeper detects RGB outputs from the KVStore record (not the + // file): the claim's transfer info must land there too or the sweep is BTC-only. + if let Err(e) = kv_store.write(RGB_PRIMARY_NS, RGB_TRANSFER_INFO_NS, &txid.to_string(), { + bincode::serialize(&transfer_info).expect("valid transfer info") + }) { + log_error!( + logger, + "Failed to persist RGB transfer info for justice {txid}: {e} \ + (RGB would not be sweepable)", + ); + return Ok(()); + } + + *claim_tx = modified_tx; + Ok(()) +} + /// Color closing transaction pub(crate) fn color_closing( channel_id: &ChannelId, closing_transaction: &mut ClosingTransaction, ldk_data_dir: &Path, @@ -701,7 +895,9 @@ pub(crate) fn color_closing( 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 (fascia, _) = wallet + .color_psbt(&mut psbt, coloring_info) + .map_err(|e| ChannelError::close(format!("RGB closing coloring failed: {e}")))?; let psbt = Psbt::from_str(&psbt.to_string()).unwrap(); let modified_tx = match psbt.extract_tx() { Ok(tx) => tx, @@ -712,7 +908,9 @@ pub(crate) fn color_closing( let txid = &modified_tx.compute_txid(); closing_transaction.built = modified_tx; - wallet.consume_fascia(fascia.clone(), Some(WitnessOrd::Ignored)).unwrap(); + wallet + .consume_fascia(fascia, Some(WitnessOrd::Ignored)) + .map_err(|e| ChannelError::close(format!("RGB closing consume failed: {e}")))?; let transfer_info = TransferInfo { contract_id, output_map }; kv_store.write_rgb_transfer_info(&txid.to_string(), &transfer_info); diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index d00e29e68..0859e5ab4 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -455,7 +455,7 @@ where CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, &stored_key, )?), - (&*entropy_source, &*signer_provider), + (&*entropy_source, &*signer_provider, None, None), ) { Ok(Some((block_hash, channel_monitor))) => { let monitor_name = MonitorName::from_str(&stored_key)?; @@ -627,7 +627,8 @@ where /// implementation for all [`KVStoreSync`]s. pub fn new( kv_store: K, logger: L, maximum_pending_updates: u64, entropy_source: ES, - signer_provider: SP, broadcaster: BI, fee_estimator: FE, + signer_provider: SP, broadcaster: BI, fee_estimator: FE, ldk_data_dir: Option, + rgb_kv_store: Option>, ) -> Self { // Note that calling the spawner only happens in the `pub(crate)` `spawn_*` methods defined // with additional bounds on `MonitorUpdatingPersisterAsync`. Thus its safe to provide a @@ -641,6 +642,8 @@ where signer_provider, broadcaster, fee_estimator, + ldk_data_dir, + rgb_kv_store, )) } @@ -823,6 +826,8 @@ struct MonitorUpdatingPersisterAsyncInner< signer_provider: SP, broadcaster: BI, fee_estimator: FE, + ldk_data_dir: Option, + rgb_kv_store: Option>, } impl @@ -841,6 +846,7 @@ where pub fn new( kv_store: K, future_spawner: S, logger: L, maximum_pending_updates: u64, entropy_source: ES, signer_provider: SP, broadcaster: BI, fee_estimator: FE, + ldk_data_dir: Option, rgb_kv_store: Option>, ) -> Self { MonitorUpdatingPersisterAsync(Arc::new(MonitorUpdatingPersisterAsyncInner { kv_store, @@ -852,6 +858,8 @@ where signer_provider, broadcaster, fee_estimator, + ldk_data_dir, + rgb_kv_store, })) } @@ -1096,7 +1104,7 @@ where } match ::EcdsaSigner>)>>::read( &mut monitor_cursor, - (&*self.entropy_source, &*self.signer_provider), + (&*self.entropy_source, &*self.signer_provider, self.ldk_data_dir.clone(), self.rgb_kv_store.clone()), ) { Ok(None) => Ok(None), Ok(Some((blockhash, channel_monitor))) => { @@ -1600,6 +1608,8 @@ mod tests { &chanmon_cfgs[0].keys_manager, &chanmon_cfgs[0].tx_broadcaster, &chanmon_cfgs[0].fee_estimator, + None, + None, ); let kv_store_1 = TestStore::new(false); let persister_1 = MonitorUpdatingPersister::new( @@ -1610,6 +1620,8 @@ mod tests { &chanmon_cfgs[1].keys_manager, &chanmon_cfgs[1].tx_broadcaster, &chanmon_cfgs[1].fee_estimator, + None, + None, ); let mut node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let chain_mon_0 = test_utils::TestChainMonitor::new( @@ -1792,6 +1804,8 @@ mod tests { node_cfgs[0].keys_manager, node_cfgs[0].tx_broadcaster, node_cfgs[0].fee_estimator, + None, + None, ); let monitor_name = added_monitors[0].1.persistence_key(); match ro_persister.persist_new_channel(monitor_name, &added_monitors[0].1) { @@ -1839,6 +1853,8 @@ mod tests { &chanmon_cfgs[0].keys_manager, &chanmon_cfgs[0].tx_broadcaster, &chanmon_cfgs[0].fee_estimator, + None, + None, ); let kv_store_1 = TestStore::new(false); let persister_1 = MonitorUpdatingPersister::new( @@ -1849,6 +1865,8 @@ mod tests { &chanmon_cfgs[1].keys_manager, &chanmon_cfgs[1].tx_broadcaster, &chanmon_cfgs[1].fee_estimator, + None, + None, ); let mut node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let chain_mon_0 = test_utils::TestChainMonitor::new( diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 5c566bdbe..05e79d8f9 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -536,7 +536,7 @@ impl<'a> TestChainMonitor<'a> { monitor.write(&mut w).unwrap(); let new_monitor = <(BlockHash, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), - (self.keys_manager, self.keys_manager), + (self.keys_manager, self.keys_manager, None, None), ) .unwrap() .1; @@ -573,7 +573,7 @@ impl<'a> chain::Watch for TestChainMonitor<'a> { monitor.write(&mut w).unwrap(); let new_monitor = <(BlockHash, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), - (self.keys_manager, self.keys_manager), + (self.keys_manager, self.keys_manager, None, None), ) .unwrap() .1; @@ -629,7 +629,7 @@ impl<'a> chain::Watch for TestChainMonitor<'a> { monitor.write(&mut w).unwrap(); let new_monitor = <(BlockHash, ChannelMonitor)>::read( &mut io::Cursor::new(&w.0), - (self.keys_manager, self.keys_manager), + (self.keys_manager, self.keys_manager, None, None), ) .unwrap() .1;