Skip to content

Commit f6bcf4f

Browse files
jkczyzclaude
andcommitted
Adopt the splice-time PaymentId when classifying a splice
A user-initiated splice will be keyed by a PaymentId generated at splice time rather than derived from a candidate's txid, so its retry intent, funding payment, and candidate history all share one record. Teach the classifier to find a pre-broadcast splice intent by its channel and reuse that id, promoting the intent record to a tracked funding payment while preserving the intent until the splice locks. Splices we did not originate (counterparty-initiated or V2 dual-funded opens) keep deriving the id from the first candidate's txid via a fallback. Also map any candidate txid back to the record in find_payment_by_txid, since a splice under a generated id is no longer found by the txid-derived lookup and an earlier RBF candidate may be the one that confirms. If the intent is already gone when classification runs, the classifier probes those same lookups for a record any candidate already created before minting a txid-derived id, so a racing wallet sync and a late classification converge on one record. The pending-store write in persist_funding_payment now happens atomically with reading the entry's prior state, and promotion of a pre-broadcast intent is gated on the payment still being Pending: a payment that confirmed through ANTI_REORG_DELAY before classification must not re-enter the pending store, which graduation and rebroadcast assume holds only Pending payments. No splice intents are created yet, so behavior is unchanged; the splice entry points that persist them follow. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1192866 commit f6bcf4f

2 files changed

Lines changed: 92 additions & 31 deletions

File tree

src/payment/pending_payment_store.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ impl PendingPaymentDetails {
169169
}
170170
}
171171

172+
/// The splice intent this record carries, if it is a splice that has not yet locked.
173+
pub(crate) fn splice_intent(&self) -> Option<&SpliceIntent> {
174+
match self {
175+
Self::PendingSplice { intent, .. } => Some(intent),
176+
Self::Tracked { splice_intent, .. } => splice_intent.as_ref(),
177+
}
178+
}
179+
172180
/// Returns this node's recorded funding figures for the candidate with the given txid, if any.
173181
pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> {
174182
match self {

src/wallet/mod.rs

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,6 +1266,24 @@ impl Wallet {
12661266
Ok(())
12671267
}
12681268

1269+
/// Returns the `PaymentId` of a user-initiated splice intent for one of the channels in
1270+
/// `candidate`, if any, so a classified splice adopts the id chosen at splice time rather than
1271+
/// deriving one from the first candidate's txid. A fee bump reuses the channel's existing intent,
1272+
/// so at most one in-flight intent matches and the first is unambiguous.
1273+
fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option<PaymentId> {
1274+
self.pending_payment_store
1275+
.list_filter(|p| {
1276+
p.splice_intent().is_some_and(|intent| {
1277+
candidate.channels.iter().any(|channel| {
1278+
channel.channel_id == intent.channel_id
1279+
&& channel.counterparty_node_id == intent.counterparty_node_id
1280+
})
1281+
})
1282+
})
1283+
.first()
1284+
.map(|p| p.id())
1285+
}
1286+
12691287
/// Records an interactive-funding broadcast (splice, or a V2 dual-funded open) as a pending
12701288
/// on-chain payment, tagged with its transaction type. Amount and fee are this node's share,
12711289
/// derived from the active candidate's contributions; broadcasts we didn't contribute to, or
@@ -1316,9 +1334,18 @@ impl Wallet {
13161334
return Ok(());
13171335
}
13181336

1319-
// Anchor the `PaymentId` to the first negotiated candidate so the record stays stable
1320-
// across RBF replacements.
1321-
let payment_id = PaymentId(first.txid.to_byte_array());
1337+
// Adopt the `PaymentId` generated when the splice was initiated so its retry intent, funding
1338+
// payment, and candidate history share one record. If the intent is already gone (e.g. the
1339+
// splice locked before this classification ran), adopt the id of a record wallet sync
1340+
// created for any candidate rather than minting a divergent one. Fall back to the first
1341+
// negotiated candidate's txid for splices we did not originate (counterparty-initiated or
1342+
// V2 opens), which keeps that id stable across RBF replacements.
1343+
let payment_id = self
1344+
.find_splice_payment_id(active)
1345+
.or_else(|| {
1346+
candidates.iter().find_map(|candidate| self.find_payment_by_txid(candidate.txid))
1347+
})
1348+
.unwrap_or_else(|| PaymentId(first.txid.to_byte_array()));
13221349

13231350
// Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a
13241351
// counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed
@@ -1411,35 +1438,57 @@ impl Wallet {
14111438
&candidates,
14121439
self.payment_store.get(&details.id).as_ref(),
14131440
);
1414-
let pending_update = PendingPaymentDetailsUpdate {
1415-
id: update.id,
1416-
payment_update: Some(update.clone()),
1417-
conflicting_txids: None,
1418-
candidates: candidates.clone(),
1419-
splice_intent: None,
1420-
};
1421-
self.payment_store.update_or_insert(update, details.clone()).await?;
1422-
1423-
// The pending index must exist exactly while the authoritative record is Pending:
1424-
// graduation and rebroadcast read it, and a graduated payment must not be re-indexed.
1425-
// Deciding by the post-write status rather than by whether the write inserted also
1426-
// repairs a missing index — a crash or failed write between the two stores leaves a
1427-
// Pending record with no entry, and a merge alone would never recreate it, leaving the
1428-
// payment unable to graduate and its txids unmapped.
1441+
self.payment_store.update_or_insert(update.clone(), details.clone()).await?;
1442+
1443+
// The post-write record decides what the pending index may hold: only `Pending` payments
1444+
// belong in the pending store (graduation and rebroadcast assume it), and a promoted or
1445+
// (re)created entry must carry the record wallet sync advanced, not the fresh
1446+
// Unconfirmed details.
14291447
let recorded = self.payment_store.get(&details.id).unwrap_or(details);
1430-
if recorded.status == PaymentStatus::Pending {
1431-
// Wallet sync can still land between the payment-store write above and this one and
1432-
// mirror an advanced confirmation into the pending store, so this write makes the
1433-
// same atomic decision: merge narrowly into an entry that appeared, insert otherwise.
1434-
// The inserted entry embeds the post-write record rather than the fresh details, so a
1435-
// confirmation wallet sync already recorded keeps driving graduation.
1436-
let pending = PendingPaymentDetails::new(recorded, Vec::new(), candidates);
1437-
self.pending_payment_store.update_or_insert(pending_update, pending).await?;
1438-
} else {
1439-
// The payment already advanced beyond Pending: the graduation path removed the
1440-
// entry, and `update`'s no-op on absence must not re-create it.
1441-
self.pending_payment_store.update(pending_update).await?;
1442-
}
1448+
let id = recorded.id;
1449+
self.pending_payment_store
1450+
.mutate(&id, |existing| match existing {
1451+
// First time we record this funding payment — or a crash between the two store
1452+
// writes left a Pending record with no index entry: (re)create it so the payment
1453+
// can graduate and its candidate txids stay mapped. A graduated payment is never
1454+
// `Pending`, so absence with an advanced record means the graduation path removed
1455+
// the entry and it must not be re-indexed.
1456+
None => (recorded.status == PaymentStatus::Pending).then(|| {
1457+
PendingPaymentDetails::tracked(recorded, Vec::new(), candidates, None)
1458+
}),
1459+
// A user-initiated splice has a pre-broadcast `PendingSplice` intent under this
1460+
// id; carry its intent into the `Tracked` record so the retrier can still clear
1461+
// it once the splice locks. If the payment already advanced beyond `Pending`
1462+
// (wallet sync confirmed it through `ANTI_REORG_DELAY` first), it must not enter
1463+
// the pending store; the intent stays for `ChannelReady` or `reconcile` to clear.
1464+
Some(PendingPaymentDetails::PendingSplice { intent, .. }) => {
1465+
if recorded.status == PaymentStatus::Pending {
1466+
Some(PendingPaymentDetails::tracked(
1467+
recorded,
1468+
Vec::new(),
1469+
candidates,
1470+
Some(intent.clone()),
1471+
))
1472+
} else {
1473+
None
1474+
}
1475+
},
1476+
// An earlier candidate or a racing wallet sync already recorded this payment:
1477+
// merge only the classification (`tx_type`, candidate history and the figures of
1478+
// whichever candidate the record's state makes authoritative) into it.
1479+
Some(tracked @ PendingPaymentDetails::Tracked { .. }) => {
1480+
let mut updated = tracked.clone();
1481+
let pending_update = PendingPaymentDetailsUpdate {
1482+
id,
1483+
payment_update: Some(update),
1484+
conflicting_txids: None,
1485+
candidates,
1486+
splice_intent: None,
1487+
};
1488+
updated.update(pending_update).then_some(updated)
1489+
},
1490+
})
1491+
.await?;
14431492
Ok(())
14441493
}
14451494

@@ -1549,6 +1598,10 @@ impl Wallet {
15491598
p.details().is_some_and(
15501599
|d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid),
15511600
) || p.conflicting_txids().contains(&target_txid)
1601+
// A splice keyed by a generated PaymentId is not found by the txid-derived id
1602+
// above, so map any of its candidate txids (an earlier RBF round may confirm)
1603+
// back to the record.
1604+
|| p.candidate(target_txid).is_some()
15521605
})
15531606
.first()
15541607
{

0 commit comments

Comments
 (0)