Skip to content

Commit ac05e6f

Browse files
authored
feat(key-wallet-manager): WalletInterface::rewind_to_height with descendant cascade (#169)
* feat(key-wallet): add rewind primitives on managed accounts Add `demote_records_above`, `demote_record`, and (for the funds variant) `rebuild_utxos` so wallet-side reorg handling can demote above-cut records and rebuild the UTXO / spent-outpoint state from the surviving records. Mirrors the post-deserialization rebuild that the funds account's serde `Deserialize` impl already uses. The keys variant only needs the context demotions since it carries no UTXO state. Both helpers leave records currently in `InstantSend`, `Conflicted`, or `Abandoned` contexts alone. * feat(key-wallet): `WalletInfoInterface::rewind_to_height` with descendant cascade Adds the wallet-level rewind orchestrator that consumes the new per-account demote / rebuild primitives. Validates the chainlock floor up front, runs an initial cut on every account, then drives a cross-account fixed-point loop that demotes any in-wallet record whose inputs spend an output of an already-demoted record. Finally rebuilds every funds account's UTXO state and rolls `last_processed_height` / `synced_height` back to `min(height, current)`. `used_addresses` markers are intentionally preserved as monotonic state. The trait method returns a `RewindOutcome` so the manager-level emitter can fire a single atomic event per wallet. Self-conflict detection and ISLock-quorum re-validation are explicitly deferred to follow-ups with in-code TODOs. * feat(key-wallet-manager): `WalletInterface::rewind_to_height` and `get_transaction` Threads the wallet-side reorg path through the manager: - new `RewindResult` and `RewindError` types - `rewind_to_height` validates the chainlock floor across every managed wallet up front, then runs the per-wallet rewind and emits a single `WalletEvent::Reorg` per wallet whose state changed - `get_transaction` lookup for the auto-rebroadcast path - `WalletEvent::Reorg` variant with the partitioned demoted-vs-conflicted txid lists and the post-rewind balance The `&mut self` receiver on `rewind_to_height` is the lock contract: implementations must not yield mid-rewind so concurrent mutators on the same trait surface cannot interleave with a reorg-driven demotion. Mock implementations in `test_utils` get the minimum stubs needed to satisfy the trait. * feat(dash-spv-ffi): handle `WalletEvent::Reorg` in callback dispatch Adds the exhaustive-match arm for the new `WalletEvent::Reorg` variant. The C ABI does not yet carry a dedicated reorg callback, so the arm logs intent via a TODO and drops the event. Wiring a real callback is tracked separately. * test(key-wallet-manager): cover `rewind_to_height` and `get_transaction` Adds seven scenarios behind the new `WalletInterface`: - single tx at H+1 demotes from `InBlock` to `Mempool` and emits a `WalletEvent::Reorg` with zero confirmed balance - TxA at H+1, TxB spending TxA at H+2 cascades both - IS-locked record is retained as `InstantSend` through the rewind - rewind below the chainlock floor is rejected with the correct error fields and a rewind at the floor itself is allowed - `last_processed_height` and `synced_height` roll back, and a rewind above the current height does not advance them forward - `get_transaction` returns stored records and `None` for unknown txids * chore(key-wallet-manager): re-sort imports after rewind addition * chore: pr cleanup — import, visibility, and comment fixes - Import `RewindError` at module top in `event_tests.rs` instead of using `crate::wallet_interface::RewindError` inline - Use imported `WalletId` instead of `crate::WalletId` in `wallet_interface.rs` - Replace em-dash prose separators in doc comments with `:` or `;` - Remove caller-reference sentence from `spend_from` doc - Rewrite `state_changed` field doc to describe the field, not its callers * fix(key-wallet): make `rebuild_utxos` order-independent and restore `is_trusted` Guards every UTXO insert against `spent_outpoints` so a record whose spending child sorts earlier (e.g. two records demoted to `Mempool` after a rewind, both with `None` heights) cannot resurrect a spent outpoint into the spendable set. Restores `is_trusted` on self-send change outputs by detecting `has_owned_input` against the rebuild's own `spent_outpoints` and matching against the account's internal pool. Post-rewind self-send change outputs that demote to `Mempool` continue to bucket into `confirmed` rather than silently undercounting. Addresses manki-review on PR [#169](#169) ([thread](#169 (comment)), [thread](#169 (comment))). * fix(key-wallet): require `rewind_to_height` and bound cascade work Removes the no-op default body of `WalletInfoInterface::rewind_to_height`. The default returned `Ok(RewindOutcome::default())` with `state_changed = false`, which silently suppresses the `WalletEvent::Reorg` event and leaves UTXOs unrebuilt — a correctness-critical mutation cannot be advisory. Any implementor that forgets to override would silently appear to succeed. Replaces the descendant cascade's full `already_demoted` re-scan with a `frontier` that carries only the previous wave. Any newly reachable descendant must spend an output of that wave, so the prior `O(D²)` loop collapses to `O(D)` for the outpoint collection step. The `already_demoted` set is retained for the candidate-skip predicate. Addresses manki-review on PR [#169](#169) ([thread](#169 (comment)), [thread](#169 (comment))). * refactor(key-wallet): delegate funds-account demote helpers to the keys account `ManagedCoreFundsAccount::demote_records_above` and `demote_record` were verbatim duplicates of the keys-account versions that routed through `self.keys.transactions_mut()`. Collapse the funds versions to one-line delegations so a future correctness fix to the demotion predicate (e.g. also skipping `InstantSend`) only needs to land on the keys account. Addresses manki-review on PR [#169](#169) ([thread](#169 (comment))). * test(key-wallet-manager): assert balance and `Reorg` event invariants on rewind Extends `test_rewind_cascades_to_descendant_in_same_account` to assert the post-rewind balance equals exactly the child output value. The parent UTXO remains claimed by the child (still in `Mempool`), so only the child's output reaches the spendable set. This pins down the order-independence of `rebuild_utxos`: without the `spent_outpoints` insert guard the parent UTXO would resurrect into the spendable set whenever the child's txid sorts before the parent's. Extends `test_rewind_below_current_does_not_advance_heights_upward` to subscribe to events before the no-op rewind and assert no `WalletEvent::Reorg` was emitted. This pins down the `state_changed` guard that suppresses spurious events. Addresses manki-review on PR [#169](#169) ([thread](#169 (comment)), [thread](#169 (comment))). * fix(key-wallet): make `has_owned_input` order-independent in `rebuild_utxos` `has_owned_input` was checking `spent_outpoints`, which only sees inputs of already-processed records. A mempool child sorted before its block parent would observe an empty set, so `is_trusted` never got restored and trusted change still landed in `unconfirmed` after rewind. Collect every owned outpoint from the active records in a pre-pass and use that set for the check, decoupling the signal from sort order. Addresses manki review comment on PR #169 #169 (comment) * test(key-wallet-manager): cover three-level cascade and `is_trusted` restoration on rewind Add `test_rewind_cascades_three_levels` so a regression that drops descendants past the first wave of the frontier cascade is caught by tests, and `test_rewind_restores_is_trusted_on_mempool_change` to lock in the `has_owned_input` fix by paying change back to an internal-pool address and asserting the demoted output lands in `confirmed()` rather than `unconfirmed()`. Addresses manki review comments on PR #169 #169 (comment) #169 (comment) * test(key-wallet-manager): assert post-rewind balance in three-level cascade Adds a `confirmed() + unconfirmed()` assertion to `test_rewind_cascades_three_levels` so a `rebuild_utxos` regression that double-removes the intermediate `tx_b`'s output or resurrects a spent ancestor is caught by the test, not just frontier-propagation regressions. Addresses manki-review review comment on PR #169 #169 (comment)
1 parent 1f1cc38 commit ac05e6f

10 files changed

Lines changed: 923 additions & 3 deletions

File tree

dash-spv-ffi/src/callbacks.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,14 @@ impl FFIWalletEventCallbacks {
12071207
cb(c_wallet_id.as_ptr(), *height, self.user_data);
12081208
}
12091209
}
1210+
WalletEvent::Reorg {
1211+
..
1212+
} => {
1213+
// TODO(issue #145): wire a dedicated FFI callback for
1214+
// wallet rewind so durable consumers see demoted /
1215+
// conflicted txid lists and the post-rewind balance.
1216+
// Until then this variant has no surface on the C ABI.
1217+
}
12101218
WalletEvent::ChainLockProcessed {
12111219
wallet_id,
12121220
chain_lock,

key-wallet-manager/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dashcore = { path = "../dash" }
2929
async-trait = "0.1"
3030
tokio = { version = "1", features = ["macros", "rt", "sync"] }
3131
tracing = "0.1"
32+
thiserror = "1.0"
3233
zeroize = { version = "1.8", features = ["derive"] }
3334
rayon = { version = "1.11", optional = true }
3435
bincode = { version = "2.0.1", optional = true }

key-wallet-manager/src/event_tests.rs

Lines changed: 285 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use super::test_helpers::*;
22
use super::*;
3-
use crate::wallet_interface::WalletInterface;
3+
use crate::wallet_interface::{RewindError, WalletInterface};
44
use dashcore::block::{Block, Header, Version};
55
use dashcore::blockdata::script::Builder;
66
use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload;
@@ -1269,3 +1269,287 @@ async fn test_block_processed_chainlocked_flag_matches_record_context() {
12691269
assert!(matches!(inserted[0].context, TransactionContext::InBlock(_)));
12701270
}
12711271
}
1272+
1273+
// ---------------------------------------------------------------------------
1274+
// Reorg path
1275+
// ---------------------------------------------------------------------------
1276+
1277+
/// Build a transaction that spends a specific outpoint of a previous
1278+
/// transaction (mimicking a child) paying back to one of the wallet's
1279+
/// addresses.
1280+
fn spend_from(parent_txid: Txid, parent_vout: u32, addr: &Address, value: u64) -> Transaction {
1281+
Transaction {
1282+
version: 2,
1283+
lock_time: 0,
1284+
input: vec![TxIn {
1285+
previous_output: OutPoint {
1286+
txid: parent_txid,
1287+
vout: parent_vout,
1288+
},
1289+
script_sig: ScriptBuf::new(),
1290+
sequence: u32::MAX,
1291+
witness: Witness::default(),
1292+
}],
1293+
output: vec![TxOut {
1294+
value,
1295+
script_pubkey: addr.script_pubkey(),
1296+
}],
1297+
special_transaction_payload: None,
1298+
}
1299+
}
1300+
1301+
#[tokio::test]
1302+
async fn test_rewind_demotes_single_above_cut_record() {
1303+
let (mut manager, wallet_id, addr) = setup_manager_with_wallet();
1304+
let tx = create_tx_paying_to(&addr, 0xb0);
1305+
let block = make_block(vec![tx.clone()], 0xb0, 100);
1306+
let wallets = BTreeSet::from([wallet_id]);
1307+
manager.process_block_for_wallets(&block, 11, &wallets).await;
1308+
1309+
let mut rx = manager.subscribe_events();
1310+
let result = manager.rewind_to_height(10).await.expect("rewind below cut succeeds");
1311+
assert_eq!(result.demoted_txids, vec![tx.txid()]);
1312+
assert!(result.conflicted_txids.is_empty());
1313+
1314+
let events = drain_events(&mut rx);
1315+
let reorg = events
1316+
.iter()
1317+
.find_map(|e| match e {
1318+
WalletEvent::Reorg {
1319+
fork_height,
1320+
demoted_txids,
1321+
conflicted_txids,
1322+
balance,
1323+
..
1324+
} => Some((*fork_height, demoted_txids.clone(), conflicted_txids.clone(), *balance)),
1325+
_ => None,
1326+
})
1327+
.expect("Reorg event must be emitted for a wallet whose state changed");
1328+
assert_eq!(reorg.0, 10);
1329+
assert_eq!(reorg.1, vec![tx.txid()]);
1330+
assert!(reorg.2.is_empty());
1331+
assert_eq!(reorg.3.confirmed(), 0, "demoted record must drop out of confirmed balance");
1332+
1333+
let info = manager.get_wallet_info(&wallet_id).expect("wallet present");
1334+
assert_eq!(info.last_processed_height(), 10);
1335+
}
1336+
1337+
#[tokio::test]
1338+
async fn test_rewind_cascades_to_descendant_in_same_account() {
1339+
let (mut manager, wallet_id, addr) = setup_manager_with_wallet();
1340+
let tx_a = create_tx_paying_to(&addr, 0xc1);
1341+
let block_a = make_block(vec![tx_a.clone()], 0xc1, 100);
1342+
let wallets = BTreeSet::from([wallet_id]);
1343+
manager.process_block_for_wallets(&block_a, 11, &wallets).await;
1344+
1345+
let tx_b = spend_from(tx_a.txid(), 0, &addr, TX_AMOUNT / 2);
1346+
let block_b = make_block(vec![tx_b.clone()], 0xc2, 200);
1347+
manager.process_block_for_wallets(&block_b, 12, &wallets).await;
1348+
1349+
let result = manager.rewind_to_height(10).await.expect("rewind succeeds");
1350+
let demoted: BTreeSet<Txid> = result.demoted_txids.iter().copied().collect();
1351+
assert!(demoted.contains(&tx_a.txid()), "parent must be demoted");
1352+
assert!(demoted.contains(&tx_b.txid()), "child spending parent must cascade");
1353+
1354+
let info = manager.get_wallet_info(&wallet_id).expect("wallet present");
1355+
assert_eq!(info.last_processed_height(), 10);
1356+
1357+
// Rewind to Mempool leaves the child still spending the parent's
1358+
// output, so only the child's own output reaches the spendable
1359+
// set. Without `rebuild_utxos`'s order-independent spent-outpoint
1360+
// guard the child's output could be wrongly resurrected as still
1361+
// spent, or the parent's output wrongly resurrected as spendable,
1362+
// depending on txid sort order.
1363+
let balance = info.balance();
1364+
let total = balance.confirmed() + balance.unconfirmed();
1365+
assert_eq!(
1366+
total,
1367+
TX_AMOUNT / 2,
1368+
"after rewind the child still spends the parent in mempool, only the child's output is a UTXO (got balance {balance:?})",
1369+
);
1370+
}
1371+
1372+
#[tokio::test]
1373+
async fn test_rewind_cascades_three_levels() {
1374+
let (mut manager, wallet_id, addr) = setup_manager_with_wallet();
1375+
let wallets = BTreeSet::from([wallet_id]);
1376+
1377+
let tx_a = create_tx_paying_to(&addr, 0xd0);
1378+
manager
1379+
.process_block_for_wallets(&make_block(vec![tx_a.clone()], 0xd0, 100), 11, &wallets)
1380+
.await;
1381+
1382+
let tx_b = spend_from(tx_a.txid(), 0, &addr, TX_AMOUNT / 2);
1383+
manager
1384+
.process_block_for_wallets(&make_block(vec![tx_b.clone()], 0xd1, 200), 12, &wallets)
1385+
.await;
1386+
1387+
let tx_c = spend_from(tx_b.txid(), 0, &addr, TX_AMOUNT / 4);
1388+
manager
1389+
.process_block_for_wallets(&make_block(vec![tx_c.clone()], 0xd2, 300), 13, &wallets)
1390+
.await;
1391+
1392+
let result = manager.rewind_to_height(10).await.expect("rewind succeeds");
1393+
let demoted: BTreeSet<Txid> = result.demoted_txids.iter().copied().collect();
1394+
// Wave 2 of the frontier cascade only fires if wave 1 propagated the
1395+
// newly-demoted parent into the next frontier. A regression that drops
1396+
// descendants beyond the first hop would still pass the two-level test
1397+
// above but fail here.
1398+
assert!(demoted.contains(&tx_a.txid()), "grandparent must be demoted");
1399+
assert!(demoted.contains(&tx_b.txid()), "parent must cascade (wave 1)");
1400+
assert!(demoted.contains(&tx_c.txid()), "child must cascade (wave 2)");
1401+
1402+
// Beyond the first cascade hop, `rebuild_utxos` must still produce a
1403+
// correct UTXO set: a regression that double-removes the intermediate
1404+
// tx_b's output, or that resurrects a spent ancestor, would inflate or
1405+
// deflate the balance without affecting the demotion list above.
1406+
let info = manager.get_wallet_info(&wallet_id).expect("wallet present");
1407+
let balance = info.balance();
1408+
let total = balance.confirmed() + balance.unconfirmed();
1409+
assert_eq!(
1410+
total,
1411+
TX_AMOUNT / 4,
1412+
"after 3-level cascade only tx_c's output remains spendable (got balance {balance:?})",
1413+
);
1414+
}
1415+
1416+
#[tokio::test]
1417+
async fn test_rewind_restores_is_trusted_on_mempool_change() {
1418+
let (mut manager, wallet_id, addr) = setup_manager_with_wallet();
1419+
let wallets = BTreeSet::from([wallet_id]);
1420+
let (_, _, change_addr) = pool_state(&manager, &wallet_id, AddressPoolType::Internal);
1421+
1422+
let tx_a = create_tx_paying_to(&addr, 0xe0);
1423+
manager
1424+
.process_block_for_wallets(&make_block(vec![tx_a.clone()], 0xe0, 100), 11, &wallets)
1425+
.await;
1426+
1427+
// tx_b spends the wallet's UTXO from tx_a and pays change back to our
1428+
// own internal pool: after rewind tx_b ends up in mempool, so the
1429+
// change output is a trusted-change UTXO that must be credited to
1430+
// `confirmed()`, not `unconfirmed()`.
1431+
let tx_b = spend_from(tx_a.txid(), 0, &change_addr, TX_AMOUNT / 2);
1432+
manager
1433+
.process_block_for_wallets(&make_block(vec![tx_b.clone()], 0xe1, 200), 12, &wallets)
1434+
.await;
1435+
1436+
manager.rewind_to_height(10).await.expect("rewind succeeds");
1437+
1438+
let info = manager.get_wallet_info(&wallet_id).expect("wallet present");
1439+
let balance = info.balance();
1440+
assert_eq!(
1441+
balance.confirmed(),
1442+
TX_AMOUNT / 2,
1443+
"trusted mempool change must be credited to confirmed() after rebuild (got {balance:?})",
1444+
);
1445+
assert_eq!(balance.unconfirmed(), 0, "no untrusted mempool outputs remain");
1446+
}
1447+
1448+
#[tokio::test]
1449+
async fn test_rewind_retains_instant_send_record() {
1450+
let (mut manager, wallet_id, addr) = setup_manager_with_wallet();
1451+
let tx = create_tx_paying_to(&addr, 0xd3);
1452+
1453+
// First sighting is an IS-locked mempool tx.
1454+
let islock = dummy_instant_lock(tx.txid());
1455+
manager.process_mempool_transaction(&tx, Some(islock.clone())).await;
1456+
1457+
let _ = manager.rewind_to_height(0).await.expect("rewind succeeds");
1458+
1459+
let info = manager.get_wallet_info(&wallet_id).expect("wallet present");
1460+
let mut found = false;
1461+
for account in info.accounts().all_accounts() {
1462+
if let Some(record) = account.transactions().get(&tx.txid()) {
1463+
assert!(
1464+
matches!(record.context, TransactionContext::InstantSend(_)),
1465+
"IS-locked record must be retained as InstantSend after rewind, got {:?}",
1466+
record.context,
1467+
);
1468+
found = true;
1469+
}
1470+
}
1471+
assert!(found, "the IS-locked transaction record must still be present after rewind");
1472+
}
1473+
1474+
#[tokio::test]
1475+
async fn test_rewind_refuses_below_chainlock_floor() {
1476+
let (mut manager, wallet_id, _addr) = setup_manager_with_wallet();
1477+
manager.apply_chain_lock(ChainLock::dummy(50));
1478+
1479+
let err = manager
1480+
.rewind_to_height(49)
1481+
.await
1482+
.expect_err("rewind below chainlock floor must be rejected");
1483+
match err {
1484+
RewindError::BelowChainLockFloor {
1485+
requested,
1486+
floor,
1487+
wallet_id: rejecting,
1488+
} => {
1489+
assert_eq!(requested, 49);
1490+
assert_eq!(floor, 50);
1491+
assert_eq!(rejecting, wallet_id);
1492+
}
1493+
}
1494+
1495+
// Rewinding to the floor itself is allowed.
1496+
manager.rewind_to_height(50).await.expect("rewind at the floor is allowed");
1497+
}
1498+
1499+
#[tokio::test]
1500+
async fn test_rewind_rolls_back_last_processed_and_synced_height() {
1501+
let (mut manager, wallet_id, _addr) = setup_manager_with_wallet();
1502+
let wallets = BTreeSet::from([wallet_id]);
1503+
let block = make_block(vec![], 0xee, 100);
1504+
manager.process_block_for_wallets(&block, 500, &wallets).await;
1505+
manager.update_wallet_synced_height(&wallet_id, 500);
1506+
1507+
let info = manager.get_wallet_info(&wallet_id).expect("wallet present");
1508+
assert_eq!(info.last_processed_height(), 500);
1509+
assert_eq!(info.synced_height(), 500);
1510+
1511+
manager.rewind_to_height(250).await.expect("rewind succeeds");
1512+
1513+
let info = manager.get_wallet_info(&wallet_id).expect("wallet present");
1514+
assert_eq!(info.last_processed_height(), 250);
1515+
assert_eq!(info.synced_height(), 250);
1516+
}
1517+
1518+
#[tokio::test]
1519+
async fn test_rewind_below_current_does_not_advance_heights_upward() {
1520+
let (mut manager, wallet_id, _addr) = setup_manager_with_wallet();
1521+
let wallets = BTreeSet::from([wallet_id]);
1522+
let block = make_block(vec![], 0xef, 100);
1523+
manager.process_block_for_wallets(&block, 100, &wallets).await;
1524+
1525+
let mut rx = manager.subscribe_events();
1526+
manager.rewind_to_height(1000).await.expect("rewind succeeds");
1527+
1528+
let info = manager.get_wallet_info(&wallet_id).expect("wallet present");
1529+
assert_eq!(
1530+
info.last_processed_height(),
1531+
100,
1532+
"rewinding to a height above current must not advance the wallet forward",
1533+
);
1534+
1535+
let reorg_events: Vec<_> = drain_events(&mut rx)
1536+
.into_iter()
1537+
.filter(|e| matches!(e, WalletEvent::Reorg { .. }))
1538+
.collect();
1539+
assert!(reorg_events.is_empty(), "no-op rewind must not emit Reorg, got {reorg_events:?}",);
1540+
}
1541+
1542+
#[tokio::test]
1543+
async fn test_get_transaction_returns_stored_record() {
1544+
let (mut manager, wallet_id, addr) = setup_manager_with_wallet();
1545+
let tx = create_tx_paying_to(&addr, 0xa9);
1546+
let block = make_block(vec![tx.clone()], 0xa9, 100);
1547+
let wallets = BTreeSet::from([wallet_id]);
1548+
manager.process_block_for_wallets(&block, 50, &wallets).await;
1549+
1550+
let fetched = manager.get_transaction(&tx.txid()).await.expect("tx must be retrievable");
1551+
assert_eq!(fetched.txid(), tx.txid());
1552+
1553+
let missing = manager.get_transaction(&Txid::from_byte_array([0xff; 32])).await;
1554+
assert!(missing.is_none(), "unknown txid must return None");
1555+
}

key-wallet-manager/src/events.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,31 @@ pub enum WalletEvent {
322322
/// the parent transaction).
323323
locked_transactions: BTreeMap<AccountType, Vec<Txid>>,
324324
},
325+
/// The wallet was rolled back to `fork_height` after a chain reorg.
326+
/// Fires once per wallet whose state actually changed (records
327+
/// demoted, or `last_processed_height` rolled back). Carries the
328+
/// partitioned demoted-vs-conflicted txid lists plus the wallet's
329+
/// post-rewind balance so consumers can persist the new state
330+
/// atomically.
331+
Reorg {
332+
/// ID of the affected wallet.
333+
wallet_id: WalletId,
334+
/// Common-ancestor height in the active chain that the wallet
335+
/// was rolled back to.
336+
fork_height: CoreBlockHeight,
337+
/// Records demoted to an active-but-unconfirmed context
338+
/// (`Mempool` or retained `InstantSend`). Empty when the
339+
/// reorg rolled `last_processed_height` back over a height
340+
/// range that contained no wallet records.
341+
demoted_txids: Vec<Txid>,
342+
/// Records demoted to a terminal inactive context
343+
/// (`Conflicted` / `Abandoned`). Currently always empty;
344+
/// self-conflict detection is deferred to a follow-up.
345+
conflicted_txids: Vec<Txid>,
346+
/// Wallet balance after the rewind. UTXO state was rebuilt
347+
/// from the surviving records before this value was computed.
348+
balance: WalletCoreBalance,
349+
},
325350
}
326351

327352
impl WalletEvent {
@@ -347,6 +372,10 @@ impl WalletEvent {
347372
| WalletEvent::ChainLockProcessed {
348373
wallet_id,
349374
..
375+
}
376+
| WalletEvent::Reorg {
377+
wallet_id,
378+
..
350379
} => *wallet_id,
351380
}
352381
}
@@ -425,6 +454,20 @@ impl fmt::Display for WalletEvent {
425454
total_txids,
426455
)
427456
}
457+
WalletEvent::Reorg {
458+
fork_height,
459+
demoted_txids,
460+
conflicted_txids,
461+
balance,
462+
..
463+
} => write!(
464+
f,
465+
"Reorg(fork_height={}, demoted={}, conflicted={}, balance={})",
466+
fork_height,
467+
demoted_txids.len(),
468+
conflicted_txids.len(),
469+
balance,
470+
),
428471
}
429472
}
430473
}

0 commit comments

Comments
 (0)