Skip to content

Commit f01e83e

Browse files
jkczyzclaude
andcommitted
Revert reorged funding payments instead of duplicating them
A funding payment's id is anchored to its first negotiated candidate, but once it confirms the record is stamped with the candidate that actually confirmed. After it graduates and its pending-store entry is removed, a later reorg event carrying the confirmed candidate's txid could no longer be resolved to the payment, so it was recorded as a duplicate generic on-chain payment rather than reverting the splice payment. RBF splices, whose confirmed candidate differs from the first, are the reachable case. Resolve such an event against the payment store by on-chain txid once the pending store no longer holds it, and revert the funding payment to pending so wallet sync re-graduates it when it reconfirms. Raised by Codex in the review of #888. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 33f1c2d commit f01e83e

2 files changed

Lines changed: 215 additions & 6 deletions

File tree

src/wallet/mod.rs

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -392,11 +392,6 @@ impl Wallet {
392392
continue;
393393
};
394394

395-
// Collect all conflict txids
396-
let mut conflict_txids: Vec<Txid> =
397-
conflicts.iter().map(|(_, conflict_txid)| *conflict_txid).collect();
398-
399-
conflict_txids.push(txid);
400395
// The payment already exists in the store at this point: `bump_fee_rbf` updates
401396
// the payment store with the replacement txid before the next sync cycle, so we
402397
// can safely fetch it here.
@@ -407,8 +402,26 @@ impl Wallet {
407402
);
408403
let payment =
409404
self.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?;
405+
406+
// A graduated funding payment is resolvable here only through
407+
// `find_payment_by_txid`'s payment-store fallback. Revert it like the
408+
// `TxUnconfirmed`/`TxDropped` arms instead of mirroring a non-`Pending` record
409+
// into the pending store, which graduation's pending-only scan would reject.
410+
if payment.status != PaymentStatus::Pending
411+
&& self.apply_funding_status_update(
412+
payment_id,
413+
txid,
414+
ConfirmationStatus::Unconfirmed,
415+
)? {
416+
continue;
417+
}
418+
419+
// Collect all conflict txids
420+
let mut conflict_txids: Vec<Txid> =
421+
conflicts.iter().map(|(_, conflict_txid)| *conflict_txid).collect();
422+
conflict_txids.push(txid);
410423
let pending_payment_details =
411-
self.create_pending_payment_from_tx(payment, conflict_txids.clone());
424+
self.create_pending_payment_from_tx(payment, conflict_txids);
412425

413426
self.runtime.block_on(
414427
self.pending_payment_store.insert_or_update(pending_payment_details),
@@ -1454,6 +1467,34 @@ impl Wallet {
14541467
return Some(replaced_details.details.id);
14551468
}
14561469

1470+
// A funding payment graduates out of the pending store, after which only the payment store
1471+
// retains it — under its first-candidate-anchored id, but stamped with the confirmed
1472+
// candidate's txid. Map a later event (e.g. a reorg returning the confirmed candidate to the
1473+
// mempool) back to that funding payment so it is reverted in place rather than duplicated as
1474+
// a generic on-chain payment under the candidate's txid. Only one funding record carries a
1475+
// given confirmed txid (its id is anchored to the first candidate and reclassification
1476+
// merges into it), so the first match is unambiguous.
1477+
if let Some(funding) = self
1478+
.payment_store
1479+
.list_filter(|p| {
1480+
matches!(
1481+
p.kind,
1482+
PaymentKind::Onchain {
1483+
txid,
1484+
tx_type:
1485+
Some(
1486+
TransactionType::Funding { .. }
1487+
| TransactionType::InteractiveFunding { .. },
1488+
),
1489+
..
1490+
} if txid == target_txid
1491+
)
1492+
})
1493+
.first()
1494+
{
1495+
return Some(funding.id);
1496+
}
1497+
14571498
None
14581499
}
14591500

@@ -1491,6 +1532,14 @@ impl Wallet {
14911532
}
14921533
}
14931534

1535+
// A reorg returning the transaction to the mempool reverts the payment to pending so wallet
1536+
// sync re-graduates it once it reconfirms. This also re-establishes the pending-store entry
1537+
// below (gated on `Pending`) that graduation removed; without it a graduated payment would
1538+
// be left `Succeeded` with an `Unconfirmed` kind and no way to re-graduate.
1539+
if matches!(confirmation_status, ConfirmationStatus::Unconfirmed) {
1540+
payment.status = PaymentStatus::Pending;
1541+
}
1542+
14941543
payment.kind =
14951544
PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type };
14961545
self.runtime.block_on(self.payment_store.insert_or_update(payment.clone()))?;

tests/integration_tests_rust.rs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,6 +1948,166 @@ async fn splice_payment_reorged_to_unconfirmed() {
19481948
node_b.stop().unwrap();
19491949
}
19501950

1951+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1952+
async fn rbf_splice_payment_reverts_after_deep_reorg() {
1953+
// A graduated RBF splice payment is anchored to the FIRST candidate's id but stamped with the
1954+
// CONFIRMED (RBF) candidate's txid. Graduation removes its pending-store entry, so a later deep
1955+
// reorg (deeper than ANTI_REORG_DELAY) that returns the confirmed candidate to the mempool must
1956+
// still map the event back to the original payment and revert it — not create a duplicate
1957+
// generic on-chain payment under the confirmed candidate's id.
1958+
1959+
// Lower incrementalrelayfee so the RBF feerate bump is relayable (as run_rbf_splice_channel_test).
1960+
let bitcoind_exe = std::env::var("BITCOIND_EXE")
1961+
.ok()
1962+
.or_else(|| corepc_node::downloaded_exe_path().ok())
1963+
.expect(
1964+
"you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature",
1965+
);
1966+
let mut bitcoind_conf = corepc_node::Conf::default();
1967+
bitcoind_conf.network = "regtest";
1968+
bitcoind_conf.args.push("-rest");
1969+
bitcoind_conf.args.push("-incrementalrelayfee=0.00000100");
1970+
let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap();
1971+
1972+
let electrs_exe = std::env::var("ELECTRS_EXE")
1973+
.ok()
1974+
.or_else(electrsd::downloaded_exe_path)
1975+
.expect("you need to provide env var ELECTRS_EXE or specify an electrsd version feature");
1976+
let mut electrsd_conf = electrsd::Conf::default();
1977+
electrsd_conf.http_enabled = true;
1978+
electrsd_conf.network = "regtest";
1979+
let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap();
1980+
let chain_source = random_chain_source(&bitcoind, &electrsd);
1981+
1982+
let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false);
1983+
1984+
let address_a = node_a.onchain_payment().new_address().unwrap();
1985+
let address_b = node_b.onchain_payment().new_address().unwrap();
1986+
let premine_amount_sat = 5_000_000;
1987+
premine_and_distribute_funds(
1988+
&bitcoind.client,
1989+
&electrsd.client,
1990+
vec![address_a, address_b],
1991+
Amount::from_sat(premine_amount_sat),
1992+
)
1993+
.await;
1994+
node_a.sync_wallets().unwrap();
1995+
node_b.sync_wallets().unwrap();
1996+
1997+
open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await;
1998+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
1999+
node_a.sync_wallets().unwrap();
2000+
node_b.sync_wallets().unwrap();
2001+
let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id());
2002+
let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id());
2003+
2004+
// node_b splices in, then RBF-bumps it: the funding payment spans two candidates, its id
2005+
// anchored to the first (original) candidate's txid.
2006+
node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap();
2007+
let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id());
2008+
expect_splice_negotiated_event!(node_b, node_a.node_id());
2009+
wait_for_tx(&electrsd.client, original_txo.txid).await;
2010+
wait_for_classified_funding_payment(&node_b, original_txo.txid).await;
2011+
node_a.sync_wallets().unwrap();
2012+
node_b.sync_wallets().unwrap();
2013+
2014+
node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()).unwrap();
2015+
let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id());
2016+
expect_splice_negotiated_event!(node_b, node_a.node_id());
2017+
assert_ne!(original_txo, rbf_txo, "RBF should produce a different funding txo");
2018+
wait_for_tx(&electrsd.client, rbf_txo.txid).await;
2019+
wait_for_classified_funding_payment(&node_b, rbf_txo.txid).await;
2020+
node_a.sync_wallets().unwrap();
2021+
node_b.sync_wallets().unwrap();
2022+
2023+
// Confirm the RBF candidate and graduate it past ANTI_REORG_DELAY (6 confirmations), which
2024+
// removes the pending-store entry.
2025+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2026+
node_a.sync_wallets().unwrap();
2027+
node_b.sync_wallets().unwrap();
2028+
expect_channel_ready_event!(node_a, node_b.node_id());
2029+
expect_channel_ready_event!(node_b, node_a.node_id());
2030+
2031+
let payment_id = PaymentId(original_txo.txid.to_byte_array());
2032+
let rbf_payment_id = PaymentId(rbf_txo.txid.to_byte_array());
2033+
2034+
// Graduated: anchored to the original candidate's id, stamped with the confirmed RBF
2035+
// candidate's txid, with no separate record under the RBF candidate's id.
2036+
let payment = node_b.payment(&payment_id).expect("splice payment graduated");
2037+
assert_eq!(payment.status, PaymentStatus::Succeeded);
2038+
assert!(matches!(
2039+
payment.kind,
2040+
PaymentKind::Onchain {
2041+
txid,
2042+
status: ConfirmationStatus::Confirmed { .. },
2043+
tx_type: Some(TransactionType::InteractiveFunding { .. }),
2044+
} if txid == rbf_txo.txid
2045+
));
2046+
assert!(
2047+
node_b.payment(&rbf_payment_id).is_none(),
2048+
"the graduated splice payment must not be duplicated under the RBF candidate's id",
2049+
);
2050+
2051+
// Deep reorg (deeper than ANTI_REORG_DELAY): drop the 6 graduation blocks and build a longer,
2052+
// transaction-free chain, returning the confirmed RBF candidate to the mempool.
2053+
let original_height =
2054+
bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks;
2055+
invalidate_blocks(&bitcoind.client, 6);
2056+
let replacement_address = bitcoind.client.new_address().expect("failed to get new address");
2057+
for _ in 0..7 {
2058+
let _res: serde_json::Value = bitcoind
2059+
.client
2060+
.call("generateblock", &[json!(replacement_address.to_string()), json!([])])
2061+
.expect("failed to generate empty block");
2062+
}
2063+
wait_for_block(&electrsd.client, original_height as usize + 1).await;
2064+
// Wait for the reorged-out RBF candidate to reappear in the mempool before syncing, so the sync
2065+
// reliably observes its TxUnconfirmed event rather than racing electrs's reindex.
2066+
wait_for_tx(&electrsd.client, rbf_txo.txid).await;
2067+
node_b.sync_wallets().unwrap();
2068+
2069+
// The reorg event for the confirmed RBF candidate's txid must map back to the original payment
2070+
// and revert it to Pending/Unconfirmed, rather than creating a duplicate generic on-chain
2071+
// payment under the RBF candidate's id.
2072+
assert!(
2073+
node_b.payment(&rbf_payment_id).is_none(),
2074+
"a reorged-out RBF splice must not produce a duplicate generic on-chain payment",
2075+
);
2076+
let payment = node_b.payment(&payment_id).expect("splice payment still exists after the reorg");
2077+
assert_eq!(payment.status, PaymentStatus::Pending);
2078+
assert!(matches!(
2079+
payment.kind,
2080+
PaymentKind::Onchain {
2081+
status: ConfirmationStatus::Unconfirmed,
2082+
tx_type: Some(TransactionType::InteractiveFunding { .. }),
2083+
..
2084+
}
2085+
));
2086+
2087+
// The revert re-established the pending-store entry, so once the RBF candidate (still in the
2088+
// mempool) reconfirms past ANTI_REORG_DELAY the payment re-graduates to Succeeded in place —
2089+
// without leaving a duplicate behind.
2090+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2091+
node_b.sync_wallets().unwrap();
2092+
let payment = node_b.payment(&payment_id).expect("splice payment re-graduated");
2093+
assert_eq!(payment.status, PaymentStatus::Succeeded);
2094+
assert!(matches!(
2095+
payment.kind,
2096+
PaymentKind::Onchain {
2097+
txid,
2098+
status: ConfirmationStatus::Confirmed { .. },
2099+
tx_type: Some(TransactionType::InteractiveFunding { .. }),
2100+
} if txid == rbf_txo.txid
2101+
));
2102+
assert!(
2103+
node_b.payment(&rbf_payment_id).is_none(),
2104+
"re-graduation must not create a duplicate payment under the RBF candidate's id",
2105+
);
2106+
2107+
node_a.stop().unwrap();
2108+
node_b.stop().unwrap();
2109+
}
2110+
19512111
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
19522112
async fn splice_in_rbf_joins_counterparty_splice() {
19532113
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)