Skip to content

Commit e5149af

Browse files
jkczyzclaude
andcommitted
Absorb sync-minted duplicates when classifying a funding round
Wallet sync can observe a funding round before classification records it as a candidate: the counterparty broadcasts an interactively funded transaction on its own, so a classification failure being retried here — or a sync poll racing the broadcast queue — leaves the round unrecorded while its events arrive. The funding-status gate rightly reports such a round foreign, and sync re-keys the event to the round's txid-derived id, minting an untyped duplicate record whose pending entry from then on shadows the funding record in txid resolution: even after the round's classification lands, every later event routes to the duplicate, the confirmation strands there, and the funding record never confirms or graduates. Fold the duplicate back in when its round becomes a recorded candidate: adopt its confirmation onto the funding record — through the same status-update path wallet sync uses, so the confirmed candidate's figures land — and remove the duplicate along with its pending entry. A duplicate for a round that never confirmed is dropped without adopting anything; the actively-broadcast candidate stays the record's current txid. The absorption runs under the classification's cross-store lock acquisition, so sync cannot interleave, and is idempotent, so the broadcast queue's classification retry can re-run it after a partial failure. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9adbdb2 commit e5149af

1 file changed

Lines changed: 211 additions & 0 deletions

File tree

src/wallet/mod.rs

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1827,6 +1827,7 @@ impl Wallet {
18271827
&self, _guard: &tokio::sync::MutexGuard<'_, ()>, details: PaymentDetails,
18281828
candidates: Vec<FundingTxCandidate>,
18291829
) -> Result<(), Error> {
1830+
let absorb_candidates = candidates.clone();
18301831
// Everything this write does depends on the record's current state, so all of it must be
18311832
// decided inside the store's critical section. When a record exists — no matter when it
18321833
// appeared — only the classification (`tx_type`) and the figures of whichever candidate
@@ -1923,6 +1924,70 @@ impl Wallet {
19231924
}
19241925
})
19251926
.await?;
1927+
1928+
// With the candidate history recorded, duplicates wallet sync minted for rounds that were
1929+
// not yet candidates can be folded back into this record. Runs after both writes so the
1930+
// funding-status gate accepts the candidates it adopts, and under the same lock
1931+
// acquisition, so sync cannot interleave; a failure surfaces to the broadcast queue's
1932+
// classification retry, which re-runs this idempotently.
1933+
self.absorb_stray_candidate_records(_guard, id, &absorb_candidates).await?;
1934+
Ok(())
1935+
}
1936+
1937+
/// Absorbs stray records wallet sync minted for this funding payment's candidates before
1938+
/// they were classified. Sync re-keys an event for a round it cannot attribute to the
1939+
/// funding record — not yet a candidate, so the funding-status gate reports it foreign — to
1940+
/// the round's txid-derived id, minting an untyped duplicate whose pending entry then
1941+
/// shadows the funding record in [`Self::find_payment_by_txid`]'s direct probe. Once the
1942+
/// round is a recorded candidate, the duplicate's confirmation (if any) belongs on the
1943+
/// funding record: adopt it, then remove the stray and its pending entry.
1944+
///
1945+
/// The caller must hold [`Self::funding_payment_update_lock`], per
1946+
/// [`Self::apply_funding_status_update_locked`]'s contract.
1947+
async fn absorb_stray_candidate_records(
1948+
&self, guard: &tokio::sync::MutexGuard<'_, ()>, id: PaymentId,
1949+
candidates: &[FundingTxCandidate],
1950+
) -> Result<(), Error> {
1951+
for candidate in candidates {
1952+
let stray_id = PaymentId(candidate.txid.to_byte_array());
1953+
if stray_id == id {
1954+
continue;
1955+
}
1956+
let stray = match self.payment_store.get(&stray_id) {
1957+
Some(stray) => stray,
1958+
None => continue,
1959+
};
1960+
// Only a duplicate view of this candidate's transaction qualifies: an untyped record
1961+
// wallet sync minted, or one a funding-typed rebroadcast classified onto it. Anything
1962+
// else keyed by the txid-derived id is left alone.
1963+
let status = match &stray.kind {
1964+
PaymentKind::Onchain {
1965+
txid,
1966+
status,
1967+
tx_type: None | Some(TransactionType::Funding { .. }),
1968+
} if *txid == candidate.txid => status.clone(),
1969+
_ => continue,
1970+
};
1971+
// Only a confirmation is worth adopting; an unconfirmed stray carries nothing the
1972+
// record needs — the actively-broadcast candidate stays the record's current txid.
1973+
if matches!(status, ConfirmationStatus::Confirmed { .. }) {
1974+
let outcome = self
1975+
.apply_funding_status_update_locked(guard, id, candidate.txid, status)
1976+
.await?;
1977+
debug_assert!(matches!(outcome, FundingStatusUpdate::Applied));
1978+
if !matches!(outcome, FundingStatusUpdate::Applied) {
1979+
// Adoption declined; keep the stray rather than discard its confirmation.
1980+
continue;
1981+
}
1982+
}
1983+
log_debug!(
1984+
self.logger,
1985+
"Absorbing stray payment record for funding candidate {}",
1986+
candidate.txid,
1987+
);
1988+
self.payment_store.remove(&stray_id).await?;
1989+
self.pending_payment_store.remove(&stray_id).await?;
1990+
}
19261991
Ok(())
19271992
}
19281993

@@ -4605,6 +4670,152 @@ mod tests {
46054670
loop_task.await.unwrap();
46064671
}
46074672

4673+
/// Wallet sync can record a genuine replacement round before classification records it as a
4674+
/// candidate — e.g. the counterparty broadcast a round whose classification failed here and
4675+
/// is still being retried. The funding-status gate then routes the round's confirmation to a
4676+
/// stray record keyed by the round's txid, whose pending entry shadows the funding record in
4677+
/// `find_payment_by_txid`'s direct probe. Once the round's classification lands, it must
4678+
/// absorb the stray — adopt its confirmation and drop the duplicate — so a single record
4679+
/// tracks the splice.
4680+
#[tokio::test]
4681+
async fn classification_absorbs_stray_records_for_its_candidates() {
4682+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
4683+
let wallet = new_test_wallet(store, false).await;
4684+
4685+
let funding_id = PaymentId([21u8; 32]);
4686+
let txid1 = Txid::from_byte_array([1u8; 32]);
4687+
let txid2 = Txid::from_byte_array([2u8; 32]);
4688+
4689+
// Round 1 classified normally.
4690+
let round1 = vec![FundingTxCandidate {
4691+
txid: txid1,
4692+
amount_msat: Some(1_000_000),
4693+
fee_paid_msat: Some(500),
4694+
}];
4695+
let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500));
4696+
wallet.persist_funding_payment(details, round1).await.unwrap();
4697+
4698+
// Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a
4699+
// stray untyped record under the txid-derived id, plus its pending entry.
4700+
let stray_id = PaymentId(txid2.to_byte_array());
4701+
let stray = PaymentDetails::new(
4702+
stray_id,
4703+
PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None },
4704+
Some(999_000),
4705+
Some(999),
4706+
PaymentDirection::Outbound,
4707+
PaymentStatus::Pending,
4708+
);
4709+
wallet.payment_store.insert_or_update(stray.clone()).await.unwrap();
4710+
wallet
4711+
.pending_payment_store
4712+
.insert_or_update(PendingPaymentDetails::new(stray, Vec::new(), Vec::new()))
4713+
.await
4714+
.unwrap();
4715+
assert_eq!(wallet.find_payment_by_txid(txid2), Some(stray_id));
4716+
4717+
// Round 2's classification lands (e.g. retried after a persistence failure).
4718+
let rounds = vec![
4719+
FundingTxCandidate {
4720+
txid: txid1,
4721+
amount_msat: Some(1_000_000),
4722+
fee_paid_msat: Some(500),
4723+
},
4724+
FundingTxCandidate {
4725+
txid: txid2,
4726+
amount_msat: Some(1_000_000),
4727+
fee_paid_msat: Some(400),
4728+
},
4729+
];
4730+
let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400));
4731+
wallet.persist_funding_payment(details, rounds).await.unwrap();
4732+
4733+
// One record: the funding record carries the stray's confirmation and the confirmed
4734+
// candidate's figures; the stray and its pending entry are gone, so the round's txid
4735+
// resolves to the funding record again.
4736+
let payments = wallet.payment_store.list_filter(|_| true);
4737+
assert_eq!(payments.len(), 1, "the stray duplicate must be absorbed");
4738+
let payment = &payments[0];
4739+
assert_eq!(payment.id, funding_id);
4740+
assert_eq!(payment.amount_msat, Some(1_000_000));
4741+
assert_eq!(payment.fee_paid_msat, Some(400));
4742+
match &payment.kind {
4743+
PaymentKind::Onchain {
4744+
txid,
4745+
status: ConfirmationStatus::Confirmed { .. },
4746+
tx_type: Some(TransactionType::InteractiveFunding { .. }),
4747+
} => assert_eq!(*txid, txid2),
4748+
kind => panic!("unexpected kind {:?}", kind),
4749+
}
4750+
assert!(wallet.pending_payment_store.get(&stray_id).is_none());
4751+
assert_eq!(wallet.find_payment_by_txid(txid2), Some(funding_id));
4752+
}
4753+
4754+
/// A stray for an *unconfirmed* round carries no state the funding record needs: absorbing
4755+
/// it removes the duplicate without touching the record's active txid or figures, and the
4756+
/// round's txid maps back to the funding record through its candidate history.
4757+
#[tokio::test]
4758+
async fn classification_drops_unconfirmed_strays_without_adopting_their_txid() {
4759+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
4760+
let wallet = new_test_wallet(store, false).await;
4761+
4762+
let funding_id = PaymentId([21u8; 32]);
4763+
let txid1 = Txid::from_byte_array([1u8; 32]);
4764+
let txid2 = Txid::from_byte_array([2u8; 32]);
4765+
4766+
// Wallet sync saw round 1 — still unconfirmed — before any classification ran.
4767+
let stray_id = PaymentId(txid1.to_byte_array());
4768+
let stray = PaymentDetails::new(
4769+
stray_id,
4770+
PaymentKind::Onchain {
4771+
txid: txid1,
4772+
status: ConfirmationStatus::Unconfirmed,
4773+
tx_type: None,
4774+
},
4775+
Some(999_000),
4776+
Some(999),
4777+
PaymentDirection::Outbound,
4778+
PaymentStatus::Pending,
4779+
);
4780+
wallet.payment_store.insert_or_update(stray.clone()).await.unwrap();
4781+
wallet
4782+
.pending_payment_store
4783+
.insert_or_update(PendingPaymentDetails::new(stray, Vec::new(), Vec::new()))
4784+
.await
4785+
.unwrap();
4786+
4787+
// Round 2 is the active broadcast; its classification lists both rounds.
4788+
let rounds = vec![
4789+
FundingTxCandidate {
4790+
txid: txid1,
4791+
amount_msat: Some(1_000_000),
4792+
fee_paid_msat: Some(500),
4793+
},
4794+
FundingTxCandidate {
4795+
txid: txid2,
4796+
amount_msat: Some(1_000_000),
4797+
fee_paid_msat: Some(400),
4798+
},
4799+
];
4800+
let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400));
4801+
wallet.persist_funding_payment(details, rounds).await.unwrap();
4802+
4803+
let payments = wallet.payment_store.list_filter(|_| true);
4804+
assert_eq!(payments.len(), 1, "the stray duplicate must be absorbed");
4805+
let payment = &payments[0];
4806+
assert_eq!(payment.id, funding_id);
4807+
// The record keeps tracking the actively-broadcast round; a stray that never confirmed
4808+
// has nothing to adopt.
4809+
match &payment.kind {
4810+
PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } => {
4811+
assert_eq!(*txid, txid2)
4812+
},
4813+
kind => panic!("unexpected kind {:?}", kind),
4814+
}
4815+
assert_eq!(payment.fee_paid_msat, Some(400));
4816+
assert_eq!(wallet.find_payment_by_txid(txid1), Some(funding_id));
4817+
}
4818+
46084819
/// Barrier test, classification-first ordering: wallet sync's confirmation handling must
46094820
/// wait for classification's two-store write pair. Classification is parked between its
46104821
/// payment-store and pending-store writes (the torn window) and only then is the

0 commit comments

Comments
 (0)