Repository navigation
fix(wallet): reconcile late inputs and publish accounting corrections #1082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
d72f553
6d67278
93b6bc6
711c213
dd4dd77
961f065
dfb8036
1322947
b6fb3a2
a27ef94
2a5e13e
561b48c
b6a740e
424a9fc
9609990
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -272,26 +272,30 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> WalletInterface for WalletM | |
| per_wallet_released | ||
| ); | ||
|
|
||
| if let Some(lock) = instant_lock { | ||
| for (wallet_id, records) in per_wallet_updated_records { | ||
| if records.is_empty() { | ||
| continue; | ||
| } | ||
| let Some(info) = self.wallet_infos.get(&wallet_id) else { | ||
| continue; | ||
| }; | ||
| let balance = info.balance(); | ||
| let account_balances = | ||
| per_wallet_account_diff.get(&wallet_id).cloned().unwrap_or_default(); | ||
| for record in records { | ||
| let event = WalletEvent::TransactionInstantLocked { | ||
| for (wallet_id, records) in per_wallet_updated_records { | ||
| let Some(info) = self.wallet_infos.get(&wallet_id) else { | ||
| continue; | ||
| }; | ||
| let balance = info.balance(); | ||
| let account_balances = | ||
| per_wallet_account_diff.get(&wallet_id).cloned().unwrap_or_default(); | ||
| for record in records { | ||
| let txid = record.txid; | ||
| self.emit_event(WalletEvent::TransactionDetected { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This now fires for every updated record on the mempool path, including a plain IS lock on a known tx, which used to emit only
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restored lock-only delivery for a known mempool transaction. The new regression failed at two events versus one and now passes. Accounting corrections still repeat Implemented/verified in dfb8036. 🤖 Co-authored by Claudius the Magnificent AI Agent |
||
| wallet_id, | ||
| record: Box::new(record), | ||
| balance, | ||
| account_balances: account_balances.clone(), | ||
| addresses_derived: Vec::new(), | ||
| }); | ||
| if let Some(lock) = instant_lock.as_ref().filter(|lock| lock.txid == txid) { | ||
| self.emit_event(WalletEvent::TransactionInstantLocked { | ||
| wallet_id, | ||
| txid: record.txid, | ||
| txid, | ||
| instant_lock: lock.clone(), | ||
| balance, | ||
| account_balances: account_balances.clone(), | ||
| }; | ||
| self.emit_event(event); | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -71,6 +71,10 @@ pub struct ManagedCoreFundsAccount { | |
| /// re-establish which coins are spent. | ||
| #[cfg_attr(feature = "serde", serde(skip))] | ||
| reservations: ReservationSet, | ||
| /// Late funding outputs awaiting attribution to account-local spender records. | ||
| /// Drained at wallet scope before returning; never persisted. | ||
| #[cfg_attr(feature = "serde", serde(skip))] | ||
| born_spent_outputs: Vec<(OutPoint, u64, Address)>, | ||
| } | ||
|
|
||
| /// What [`ManagedCoreFundsAccount::apply_abandon`] removed from one account. | ||
|
|
@@ -108,6 +112,7 @@ impl ManagedCoreFundsAccount { | |
| spent_outpoints: HashSet::new(), | ||
| spent_before_funded: BTreeMap::new(), | ||
| reservations: ReservationSet::default(), | ||
| born_spent_outputs: Vec::new(), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -136,6 +141,7 @@ impl ManagedCoreFundsAccount { | |
| spent_outpoints: HashSet::new(), | ||
| spent_before_funded: BTreeMap::new(), | ||
| reservations: ReservationSet::default(), | ||
| born_spent_outputs: Vec::new(), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -329,6 +335,11 @@ impl ManagedCoreFundsAccount { | |
| outpoint = %outpoint, | ||
| "Skipping UTXO already spent by previously processed transaction" | ||
| ); | ||
| self.born_spent_outputs.push(( | ||
| outpoint, | ||
| output.value, | ||
| addr.clone(), | ||
| )); | ||
| continue; | ||
| } | ||
|
|
||
|
|
@@ -343,6 +354,11 @@ impl ManagedCoreFundsAccount { | |
| outpoint = %outpoint, | ||
| "Skipping UTXO already observed spent in an earlier-processed block (#649)" | ||
| ); | ||
| self.born_spent_outputs.push(( | ||
| outpoint, | ||
| output.value, | ||
| addr.clone(), | ||
| )); | ||
| self.spent_before_funded.insert( | ||
| outpoint, | ||
| Utxo::new( | ||
|
|
@@ -420,6 +436,92 @@ impl ManagedCoreFundsAccount { | |
| } | ||
| } | ||
|
|
||
| /// Attribute a late funding output only to its owning account's spender slices. | ||
| pub(crate) fn attribute_spent_input( | ||
| &mut self, | ||
| outpoint: &OutPoint, | ||
| value: u64, | ||
| address: &Address, | ||
| spenders: &[TransactionRecord], | ||
| ) -> Vec<TransactionRecord> { | ||
| if !self.contains_address(address) { | ||
| return Vec::new(); | ||
| } | ||
| let mut corrected = Vec::new(); | ||
| for template in spenders { | ||
| #[cfg(not(feature = "keep-finalized-transactions"))] | ||
| if self.keys.transaction_is_finalized(&template.txid) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is what leaves a finalized spender uncorrected (point 1 of the review). The persisted row stays
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed both ChainLock arrival orders. I chose the explicit limitation disclosure permitted in the review: default retention is preserved, so already-pruned spenders and their persisted Incoming +change rows remain uncorrectable. README, checker/event/FFI docs and the PR description now state this. Applications needing corrections after finalization must enable Implemented/verified in dfb8036. 🤖 Co-authored by Claudius the Magnificent AI Agent
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tracked in #1003: #1003, which already describes the finalization blocker, including full-record pruning with default features. The remaining default-feature case will need a separate follow-up PR. #1082 corrects retained spender records (including finalized records with A follow-up should cover both ChainLock arrival orders, preserve the finalized context, and verify correction delivery and persistence/restart behavior. Deferring pruning until historical scan coverage is established is one possible approach; removing the finalized guard alone cannot recover already-pruned records. 🤖 Co-authored by Claudius the Magnificent AI Agent
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just to be clear: I see this fix as out of scope, tracked in #1003 |
||
| continue; | ||
| } | ||
| let Some(input_index) = template | ||
| .transaction | ||
| .input | ||
| .iter() | ||
| .position(|input| &input.previous_output == outpoint) | ||
| else { | ||
| continue; | ||
| }; | ||
| let mut record = | ||
| self.keys.transactions().get(&template.txid).cloned().unwrap_or_else(|| { | ||
| let mut record = template.clone(); | ||
| record.account_type = self.keys.managed_account_type().to_account_type(); | ||
| record.input_details.clear(); | ||
| record.output_details.clear(); | ||
| record | ||
| }); | ||
|
lklimek marked this conversation as resolved.
Outdated
|
||
| if record.input_details.iter().any(|d| d.index == input_index as u32) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No test fails if this guard is removed, and it is hit in the most common flow: funding in mempool, spend in mempool, then the funding is mined. Without it the spender comes out as The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added the funding-mempool → spend-mempool → funding-block regression. It asserts Outgoing -2000, one input, and no redundant spender correction. Removing the guard fails at -102000 versus -2000. A separate manager block regression verifies the late spender correction in Implemented/verified in dfb8036. 🤖 Co-authored by Claudius the Magnificent AI Agent |
||
| continue; | ||
| } | ||
| record.input_details.push(InputDetail { | ||
| index: input_index as u32, | ||
| value, | ||
| address: address.clone(), | ||
| }); | ||
| record.input_details.sort_by_key(|d| d.index); | ||
| for (index, output) in record.transaction.output.iter().enumerate() { | ||
| if record.output_details.iter().any(|detail| detail.index == index as u32) { | ||
| continue; | ||
| } | ||
| let output_address = | ||
| Address::from_script(&output.script_pubkey, self.keys.network()).ok(); | ||
| let pool = output_address.as_ref().and_then(|addr| { | ||
| self.managed_account_type() | ||
| .address_pools() | ||
| .into_iter() | ||
| .find(|pool| pool.address_index(addr).is_some()) | ||
| }); | ||
| let role = match pool { | ||
| Some(pool) if pool.pool_type == address_pool::AddressPoolType::Internal => { | ||
| OutputRole::Change | ||
| } | ||
| Some(_) => OutputRole::Received, | ||
| None if output.script_pubkey.is_provably_unspendable() => { | ||
| OutputRole::Unspendable | ||
| } | ||
| None => OutputRole::Sent, | ||
| }; | ||
| record.output_details.push(OutputDetail { | ||
| index: index as u32, | ||
| value: output.value, | ||
| address: output_address, | ||
| role, | ||
| }); | ||
| } | ||
| record.output_details.sort_by_key(|detail| detail.index); | ||
| record.recompute_net_and_direction(); | ||
| self.keys.transactions_mut().insert(record.txid, record.clone()); | ||
| self.spent_outpoints.insert(*outpoint); | ||
| corrected.push(record); | ||
| } | ||
| corrected | ||
| } | ||
|
|
||
| /// Drain the born-spent outputs staged by [`Self::update_utxos`] since | ||
| /// the last drain, for the wallet-scope attribution sweep. | ||
| pub(crate) fn take_born_spent_outputs(&mut self) -> Vec<(OutPoint, u64, Address)> { | ||
| std::mem::take(&mut self.born_spent_outputs) | ||
| } | ||
|
|
||
| /// Drop the spent-marks that `freed` contributed, keeping every mark a | ||
| /// surviving record still claims. | ||
| /// | ||
|
|
@@ -1337,6 +1439,7 @@ impl<'de> Deserialize<'de> for ManagedCoreFundsAccount { | |
| spent_outpoints, | ||
| spent_before_funded: helper.spent_before_funded, | ||
| reservations: ReservationSet::default(), | ||
| born_spent_outputs: Vec::new(), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.