Skip to content

Commit 26afff9

Browse files
jkczyzclaude
andcommitted
Resolve a transaction to the record that owns it before one listing it as a conflict
A pending-store entry lists the transactions that replaced its own, so a cooperative close (or any other wallet transaction) that a splice round replaces lists the round among its conflicting txids. The round's events then matched two entries, its own record's and the close's, and the pending cache's iteration order decided which one won. About one time in five the round's confirmation landed on the close's record, which took the round's txid, figures and confirmation and graduated, while the splice's payment never learned of the confirmation and stayed pending for good. Prefer the entry that records the transaction as its own, whether as its current transaction or as a negotiated candidate, and fall back to an entry that only lists it as a conflict when no entry owns it. The conflict listing stays: it is how a replaced round of a record without candidates, an ordinary payment's RBF history or the replacement of an inbound transaction, maps back to its record. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 4f82716 commit 26afff9

1 file changed

Lines changed: 148 additions & 13 deletions

File tree

‎src/wallet/mod.rs‎

Lines changed: 148 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2810,20 +2810,23 @@ impl Wallet {
28102810
return Ok(Some(direct_payment_id));
28112811
}
28122812

2813-
if let Some(replaced_details) = self
2813+
let owns = |p: &PendingPaymentDetails| {
2814+
matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid)
2815+
// A middle RBF round is not the record's current txid and may never have
2816+
// received a `TxReplaced` event of its own, so map any of its candidate
2817+
// txids (an earlier RBF round may confirm) back to the record.
2818+
|| p.candidate(target_txid).is_some()
2819+
};
2820+
let matches = self
28142821
.pending_payment_store
2815-
.list_filter(|p| {
2816-
matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid)
2817-
|| p.conflicting_txids.contains(&target_txid)
2818-
// A middle RBF round is not the record's current txid and may never have
2819-
// received a `TxReplaced` event of its own, so map any of its candidate
2820-
// txids (an earlier RBF round may confirm) back to the record.
2821-
|| p.candidate(target_txid).is_some()
2822-
})
2823-
.await
2824-
.first()
2825-
{
2826-
return Ok(Some(replaced_details.details.id));
2822+
.list_filter(|p| owns(p) || p.conflicting_txids.contains(&target_txid))
2823+
.await;
2824+
// An entry lists the transactions that replaced its own, so a transaction another entry
2825+
// records as its own (a splice round that replaced a close, say) matches both. The entry
2826+
// that owns it is its record; the conflict listing is only how a replaced round of a
2827+
// record with no candidates (an ordinary payment's RBF history) maps back to its record.
2828+
if let Some(entry) = matches.iter().find(|p| owns(p)).or(matches.first()) {
2829+
return Ok(Some(entry.details.id));
28272830
}
28282831

28292832
Ok(None)
@@ -6358,6 +6361,138 @@ mod tests {
63586361
}
63596362
}
63606363

6364+
/// The mirror image of [`funding_record_does_not_adopt_a_conflicting_close`]: a cooperative
6365+
/// close that a splice round replaces lists the round among its conflicting txids, so the
6366+
/// round's confirmation resolves to the close's entry as readily as to the splice's, which
6367+
/// records the round as its own. It must land on the splice's record whichever entry the
6368+
/// pending cache lists first: the close's record is not the round's, and merging the round
6369+
/// into it leaves the splice's payment pending for good. Several closes and several fresh
6370+
/// wallets, each with its own cache order, make the splice's entry unlikely to come first
6371+
/// every time.
6372+
#[tokio::test]
6373+
async fn close_record_does_not_adopt_a_conflicting_splice_round() {
6374+
let secp = bitcoin::secp256k1::Secp256k1::new();
6375+
let counterparty_node_id = bitcoin::secp256k1::PublicKey::from_secret_key(
6376+
&secp,
6377+
&bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(),
6378+
);
6379+
let channel_id = lightning::ln::types::ChannelId::from_bytes([4u8; 32]);
6380+
6381+
for _ in 0..12 {
6382+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
6383+
let wallet = new_test_wallet(store, false).await;
6384+
6385+
// The round pays a wallet address, so the wallet's view of it carries figures of its own.
6386+
let script_pubkey = wallet
6387+
.inner
6388+
.lock()
6389+
.unwrap()
6390+
.reveal_next_address(KeychainKind::External)
6391+
.address
6392+
.script_pubkey();
6393+
let splice_tx = Transaction {
6394+
version: bitcoin::transaction::Version::TWO,
6395+
lock_time: LockTime::ZERO,
6396+
input: vec![bitcoin::TxIn {
6397+
previous_output: bitcoin::OutPoint {
6398+
txid: Txid::from_byte_array([3u8; 32]),
6399+
vout: 0,
6400+
},
6401+
script_sig: bitcoin::ScriptBuf::new(),
6402+
sequence: bitcoin::Sequence::MAX,
6403+
witness: bitcoin::Witness::new(),
6404+
}],
6405+
output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }],
6406+
};
6407+
let splice_txid = splice_tx.compute_txid();
6408+
// Keyed away from the round's txid, as a later round of a splice is.
6409+
let payment_id = PaymentId([21u8; 32]);
6410+
let candidates = vec![FundingTxCandidate {
6411+
txid: splice_txid,
6412+
amount_msat: Some(1_000_000),
6413+
fee_paid_msat: Some(500),
6414+
awaiting_broadcast: false,
6415+
}];
6416+
let details =
6417+
interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500));
6418+
wallet.persist_funding_payment(details, candidates).await.unwrap();
6419+
6420+
// Each close was recorded at broadcast, seen unconfirmed, then replaced by the round:
6421+
// what the `TxReplaced` arm leaves behind.
6422+
let close_txids: Vec<Txid> =
6423+
(7u8..11).map(|byte| Txid::from_byte_array([byte; 32])).collect();
6424+
for close_txid in &close_txids {
6425+
let close_details = PaymentDetails::new(
6426+
PaymentId(close_txid.to_byte_array()),
6427+
PaymentKind::Onchain {
6428+
txid: *close_txid,
6429+
status: ConfirmationStatus::Unconfirmed,
6430+
tx_type: Some(TransactionType::CooperativeClose {
6431+
counterparty_node_id,
6432+
channel_id,
6433+
}),
6434+
},
6435+
Some(50_000_000),
6436+
Some(1_000),
6437+
PaymentDirection::Inbound,
6438+
PaymentStatus::Pending,
6439+
);
6440+
wallet.payment_store.insert_or_update(close_details.clone()).await.unwrap();
6441+
let entry =
6442+
PendingPaymentDetails::new(close_details, vec![splice_txid], Vec::new());
6443+
wallet.pending_payment_store.insert_or_update(entry).await.unwrap();
6444+
}
6445+
6446+
assert_eq!(
6447+
wallet.find_payment_by_txid(splice_txid).await.unwrap(),
6448+
Some(payment_id),
6449+
"the round resolved to a record that only lists it as a conflict"
6450+
);
6451+
6452+
let event = WalletEvent::TxConfirmed {
6453+
txid: splice_txid,
6454+
tx: Arc::new(splice_tx),
6455+
block_time: confirmed_block_time(5),
6456+
old_block_time: None,
6457+
};
6458+
wallet.update_payment_store(vec![event]).await.unwrap();
6459+
6460+
let funding = wallet.payment_store.get(&payment_id).await.unwrap().unwrap();
6461+
match &funding.kind {
6462+
PaymentKind::Onchain { txid, status, tx_type } => {
6463+
assert_eq!(*txid, splice_txid);
6464+
assert!(matches!(status, ConfirmationStatus::Confirmed { .. }));
6465+
assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. })));
6466+
},
6467+
kind => panic!("unexpected kind {:?}", kind),
6468+
}
6469+
assert_eq!(funding.amount_msat, Some(1_000_000));
6470+
assert_eq!(funding.fee_paid_msat, Some(500));
6471+
6472+
for close_txid in &close_txids {
6473+
let close = wallet
6474+
.payment_store
6475+
.get(&PaymentId(close_txid.to_byte_array()))
6476+
.await
6477+
.unwrap()
6478+
.unwrap();
6479+
match &close.kind {
6480+
PaymentKind::Onchain { txid, status, tx_type } => {
6481+
assert_eq!(
6482+
*txid, *close_txid,
6483+
"the close's record adopted the round's txid"
6484+
);
6485+
assert!(matches!(status, ConfirmationStatus::Unconfirmed));
6486+
assert!(matches!(tx_type, Some(TransactionType::CooperativeClose { .. })));
6487+
},
6488+
kind => panic!("unexpected kind {:?}", kind),
6489+
}
6490+
assert_eq!(close.amount_msat, Some(50_000_000));
6491+
assert_eq!(close.fee_paid_msat, Some(1_000));
6492+
}
6493+
}
6494+
}
6495+
63616496
/// Continues the story above: once the conflicting close confirms through the anti-reorg
63626497
/// depth, the splice's funding transaction can never confirm — its shared input is spent for
63636498
/// good. The record must fail rather than stay `Pending` forever, and removing the pending

0 commit comments

Comments
 (0)