Skip to content

Commit 08994a9

Browse files
committed
lightning: ignore claims for pending spent outpoints
When a transaction spends one outpoint from a delayed package, the split outpoint is tracked as a ContentiousOutpoint while it awaits anti-reorg confirmation. Reject replayed claim requests for those pending-spent outpoints so they are not added back before the spend either matures or reorgs out. Add an OnchainTxHandler regression that replays a holder claim during that pending-spent window and verifies reorg resurrection still works.
1 parent 7bc1f70 commit 08994a9

1 file changed

Lines changed: 136 additions & 3 deletions

File tree

lightning/src/chain/onchaintx.rs

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,16 @@ impl<ChannelSigner: EcdsaChannelSigner> OnchainTxHandler<ChannelSigner> {
576576
self.pending_claim_requests.len() != 0
577577
}
578578

579+
fn is_outpoint_spend_waiting_threshold_conf(&self, outpoint: &BitcoinOutPoint) -> bool {
580+
self.onchain_events_awaiting_threshold_conf.iter().any(|entry| {
581+
if let OnchainEvent::ContentiousOutpoint { ref package } = entry.event {
582+
package.contains_outpoint(outpoint)
583+
} else {
584+
false
585+
}
586+
})
587+
}
588+
579589
/// Lightning security model (i.e being able to redeem/timeout HTLC or penalize counterparty
580590
/// onchain) lays on the assumption of claim transactions getting confirmed before timelock
581591
/// expiration (CSV or CLTV following cases). In case of high-fee spikes, claim tx may get stuck
@@ -802,7 +812,15 @@ impl<ChannelSigner: EcdsaChannelSigner> OnchainTxHandler<ChannelSigner> {
802812
// First drop any duplicate claims.
803813
requests.retain(|req| {
804814
let outpoint = req.outpoint();
805-
if self.claimable_outpoints.get(outpoint).is_some() {
815+
if self.is_outpoint_spend_waiting_threshold_conf(outpoint) {
816+
// This is a package-layer guard. ChannelMonitor filters regenerated
817+
// HTLC claims using HTLC resolution state, while this keeps outpoints
818+
// split from an existing package from being re-added during the reorg
819+
// window.
820+
log_info!(logger, "Ignoring claim for outpoint {}:{}, it is already spent by a transaction awaiting anti-reorg confirmation",
821+
outpoint.txid, outpoint.vout);
822+
false
823+
} else if self.claimable_outpoints.get(outpoint).is_some() {
806824
log_info!(logger, "Ignoring second claim for outpoint {}:{}, already registered its claiming request",
807825
outpoint.txid, outpoint.vout);
808826
false
@@ -1276,11 +1294,14 @@ impl<ChannelSigner: EcdsaChannelSigner> OnchainTxHandler<ChannelSigner> {
12761294

12771295
#[cfg(test)]
12781296
mod tests {
1279-
use bitcoin::hash_types::Txid;
1297+
use bitcoin::hash_types::{BlockHash, Txid};
12801298
use bitcoin::hashes::sha256::Hash as Sha256;
12811299
use bitcoin::hashes::Hash;
1300+
use bitcoin::locktime::absolute::LockTime;
1301+
use bitcoin::transaction::{OutPoint as BitcoinOutPoint, Version};
12821302
use bitcoin::Network;
1283-
use bitcoin::{key::Secp256k1, secp256k1::PublicKey, secp256k1::SecretKey, ScriptBuf};
1303+
use bitcoin::{key::Secp256k1, secp256k1::PublicKey, secp256k1::SecretKey};
1304+
use bitcoin::{Amount, ScriptBuf, Transaction, TxIn, TxOut};
12841305
use types::features::ChannelTypeFeatures;
12851306

12861307
use crate::chain::chaininterface::{ConfirmationTarget, LowerBoundedFeeEstimator};
@@ -1404,6 +1425,18 @@ mod tests {
14041425
requests
14051426
}
14061427

1428+
fn locked_outpoints(
1429+
tx_handler: &OnchainTxHandler<InMemorySigner>, locktime: u32,
1430+
) -> Vec<BitcoinOutPoint> {
1431+
tx_handler
1432+
.locktimed_packages
1433+
.get(&locktime)
1434+
.into_iter()
1435+
.flat_map(|packages| packages.iter())
1436+
.flat_map(|package| package.outpoints().into_iter().map(|outpoint| *outpoint))
1437+
.collect()
1438+
}
1439+
14071440
// Test that all claims with locktime equal to or less than the current height are broadcast
14081441
// immediately while claims with locktime greater than the current height are only broadcast
14091442
// once the locktime is reached.
@@ -1569,4 +1602,104 @@ mod tests {
15691602
_ => panic!("expected a single HTLC bump event"),
15701603
}
15711604
}
1605+
1606+
#[test]
1607+
fn test_replayed_claim_ignored_for_pending_spent_outpoint() {
1608+
let claim_height = 21;
1609+
let spend_height = 22;
1610+
let locktime = 42;
1611+
let mut nondust_htlcs = Vec::new();
1612+
for i in 0..2 {
1613+
let preimage = PaymentPreimage([i + 1; 32]);
1614+
let hash = PaymentHash(Sha256::hash(&preimage.0[..]).to_byte_array());
1615+
nondust_htlcs.push(HTLCOutputInCommitment {
1616+
offered: true,
1617+
amount_msat: 10000,
1618+
cltv_expiry: locktime,
1619+
payment_hash: hash,
1620+
transaction_output_index: Some(i as u32),
1621+
});
1622+
}
1623+
1624+
let mut tx_handler = new_test_tx_handler(
1625+
ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies(),
1626+
nondust_htlcs,
1627+
);
1628+
let requests = build_offered_holder_htlc_requests(&tx_handler);
1629+
let spent_outpoint = *requests[0].outpoint();
1630+
let still_delayed_outpoint = *requests[1].outpoint();
1631+
let destination_script = ScriptBuf::new();
1632+
let broadcaster = TestBroadcaster::new(Network::Testnet);
1633+
let fee_estimator = TestFeeEstimator::new(253);
1634+
let fee_estimator = LowerBoundedFeeEstimator::new(&fee_estimator);
1635+
let logger = TestLogger::new();
1636+
1637+
// Register both holder HTLC claims as one delayed package before any
1638+
// individual outpoint spends are observed.
1639+
tx_handler.update_claims_view_from_requests(
1640+
requests.clone(),
1641+
claim_height,
1642+
claim_height,
1643+
&&broadcaster,
1644+
ConfirmationTarget::UrgentOnChainSweep,
1645+
&destination_script,
1646+
&fee_estimator,
1647+
&logger,
1648+
);
1649+
assert_eq!(locked_outpoints(&tx_handler, locktime).len(), 2);
1650+
1651+
// Spend one outpoint before the package matures. The handler should split
1652+
// it into a ContentiousOutpoint until the anti-reorg threshold passes.
1653+
let spend_tx = Transaction {
1654+
version: Version::TWO,
1655+
lock_time: LockTime::ZERO,
1656+
input: vec![TxIn { previous_output: spent_outpoint, ..Default::default() }],
1657+
output: vec![TxOut { value: Amount::from_sat(1000), script_pubkey: ScriptBuf::new() }],
1658+
};
1659+
tx_handler.update_claims_view_from_matched_txn(
1660+
&[&spend_tx],
1661+
spend_height,
1662+
BlockHash::all_zeros(),
1663+
spend_height,
1664+
&&broadcaster,
1665+
ConfirmationTarget::UrgentOnChainSweep,
1666+
&destination_script,
1667+
&fee_estimator,
1668+
&logger,
1669+
);
1670+
let locked = locked_outpoints(&tx_handler, locktime);
1671+
assert_eq!(locked, vec![still_delayed_outpoint]);
1672+
1673+
// Replaying both original claim requests during that window must not
1674+
// re-add the already-spent outpoint to the delayed package.
1675+
tx_handler.update_claims_view_from_requests(
1676+
requests,
1677+
spend_height,
1678+
spend_height,
1679+
&&broadcaster,
1680+
ConfirmationTarget::UrgentOnChainSweep,
1681+
&destination_script,
1682+
&fee_estimator,
1683+
&logger,
1684+
);
1685+
let locked = locked_outpoints(&tx_handler, locktime);
1686+
assert_eq!(locked, vec![still_delayed_outpoint]);
1687+
assert!(tx_handler.pending_claim_requests.is_empty());
1688+
assert!(tx_handler.claimable_outpoints.is_empty());
1689+
1690+
// If the spend reorgs out, the contentious outpoint is resurrected into
1691+
// the delayed package.
1692+
tx_handler.blocks_disconnected(
1693+
spend_height - 1,
1694+
&&broadcaster,
1695+
ConfirmationTarget::UrgentOnChainSweep,
1696+
&destination_script,
1697+
&fee_estimator,
1698+
&logger,
1699+
);
1700+
let locked = locked_outpoints(&tx_handler, locktime);
1701+
assert_eq!(locked.len(), 2);
1702+
assert!(locked.contains(&spent_outpoint));
1703+
assert!(locked.contains(&still_delayed_outpoint));
1704+
}
15721705
}

0 commit comments

Comments
 (0)