@@ -1061,29 +1061,63 @@ impl PlatformPaymentAddressProvider {
10611061 }
10621062 }
10631063 }
1064+ // Derivation-index conflict: `entry.address` isn't yet in the
1065+ // bijection, but its `address_index` already maps to a
1066+ // DIFFERENT address. Detected BEFORE the `found` mutation
1067+ // because a conflicting credit must not be half-applied.
1068+ let index_conflict = state. addresses . get_by_right ( & entry. address ) . is_none ( )
1069+ && state. addresses . contains_left ( & entry. address_index ) ;
1070+
1071+ if index_conflict && !is_removal {
1072+ // A credit under a conflicting index: dropping it outright
1073+ // is the only safe response. Inserting the pairing would
1074+ // evict the existing one (`BiBTreeMap::insert` drops
1075+ // conflicting pairs, orphaning the other address's `found`
1076+ // entry); NOT inserting it would commit a `found` balance
1077+ // downstream can't pair with a derivation index, so
1078+ // `current_balances` couldn't round-trip the committed
1079+ // seed. The address stays unresolved until `initialize` /
1080+ // `add_provider` re-snapshots the account set.
1081+ tracing:: error!(
1082+ account_index = entry. account_index,
1083+ address_index = entry. address_index,
1084+ address = %entry. address,
1085+ "commit_reconciliation: derivation index already maps to a \
1086+ different address — dropping the credit reconciliation entry \
1087+ to avoid corrupting the bijection"
1088+ ) ;
1089+ continue ;
1090+ }
1091+
10641092 if is_removal {
10651093 state. found . remove ( & entry. address ) ;
10661094 } else {
10671095 state. found . insert ( entry. address , entry. funds ) ;
10681096 }
1069- // Merge pool-resolved addresses into the bijection so
1070- // `current_balances` can pair the fresh funds with a
1071- // derivation index. Never overwrite an existing pairing —
1072- // `BiBTreeMap::insert` evicts conflicting pairs, which
1073- // would orphan another address's `found` entry.
1074- if state. addresses . get_by_right ( & entry. address ) . is_none ( ) {
1075- if state. addresses . contains_left ( & entry. address_index ) {
1076- tracing:: error!(
1077- account_index = entry. account_index,
1078- address_index = entry. address_index,
1079- address = %entry. address,
1080- "commit_reconciliation: derivation index already \
1081- maps to a different address — state drift; \
1082- leaving the bijection untouched"
1083- ) ;
1084- } else {
1085- state. addresses . insert ( entry. address_index , entry. address ) ;
1086- }
1097+
1098+ if index_conflict {
1099+ // A removal under a conflicting index: the credit case
1100+ // already `continue`d above, so this is a zero-out. It
1101+ // MUST still zero `found` (done) and be emitted (below) so
1102+ // the durable persister writes the zero — otherwise a
1103+ // stale persisted balance for this address resurrects
1104+ // after restart. Only the bijection merge is skipped, so
1105+ // the pre-existing `(index -> other address)` pairing
1106+ // survives.
1107+ tracing:: warn!(
1108+ account_index = entry. account_index,
1109+ address_index = entry. address_index,
1110+ address = %entry. address,
1111+ "commit_reconciliation: derivation index already maps to a \
1112+ different address — applying the removal without touching \
1113+ the bijection so a stale persisted balance can't resurrect"
1114+ ) ;
1115+ } else if state. addresses . get_by_right ( & entry. address ) . is_none ( ) {
1116+ // Merge pool-resolved addresses into the bijection so
1117+ // `current_balances` can pair the fresh funds with a
1118+ // derivation index. The conflict guard above ruled out an
1119+ // eviction, so this insert is always a fresh pairing.
1120+ state. addresses . insert ( entry. address_index , entry. address ) ;
10871121 }
10881122 outcome. entries . push ( entry) ;
10891123 }
@@ -1858,6 +1892,126 @@ mod tests {
18581892 assert_eq ! ( seed[ 0 ] . 2 , funds( 700 , 5 ) ) ;
18591893 }
18601894
1895+ /// A zero-funds removal that pool-resolves to an `address_index`
1896+ /// already paired to a DIFFERENT address in the bijection must still
1897+ /// zero the in-memory `found` row AND be emitted downstream —
1898+ /// otherwise a durable persister row for the removed address would
1899+ /// resurrect after restart. The bijection stays untouched so the
1900+ /// pre-existing `(index -> other addr)` pairing isn't evicted.
1901+ #[ test]
1902+ fn commit_reconciliation_index_conflict_still_emits_removal ( ) {
1903+ let owned = p2pkh ( 0x11 ) ;
1904+ let conflicting = p2pkh ( 0x77 ) ;
1905+ let mut provider = provider_with_one_funded_address ( owned, funds ( 700 , 3 ) ) ;
1906+ {
1907+ let state = provider
1908+ . per_wallet
1909+ . get_mut ( & WALLET )
1910+ . and_then ( |s| s. get_mut ( & ACCOUNT ) )
1911+ . expect ( "account state present" ) ;
1912+ // Pin `conflicting` at index 5 with a balance we must protect.
1913+ state. insert_persisted_entry ( 5 , conflicting, funds ( 200 , 1 ) ) ;
1914+ // Stale `found` row for the address that will be removed,
1915+ // seeded at the SAME index 5 to force the conflict.
1916+ state. found . insert ( p2pkh ( 0x22 ) , funds ( 999 , 4 ) ) ;
1917+ }
1918+
1919+ // The removed address pool-resolves to index 5 — a DIFFERENT
1920+ // address than the bijection holds there.
1921+ let removed = p2pkh ( 0x22 ) ;
1922+ let removed_addr = PlatformAddress :: P2pkh ( [ 0x22 ; 20 ] ) ;
1923+ let mut pool_indexes = BTreeMap :: new ( ) ;
1924+ pool_indexes. insert ( removed, ( ACCOUNT , 5u32 ) ) ;
1925+
1926+ let mut address_infos = AddressInfos :: new ( ) ;
1927+ // Fully-consumed input: Drive elides the info → removal entry.
1928+ address_infos. insert ( removed_addr, None ) ;
1929+
1930+ let outcome = provider. commit_reconciliation ( & WALLET , & address_infos, & pool_indexes) ;
1931+
1932+ // The removal is emitted so the durable persister writes the zero.
1933+ assert_eq ! ( outcome. entries. len( ) , 1 , "removal survives the guard" ) ;
1934+ assert_eq ! ( outcome. entries[ 0 ] . address, removed) ;
1935+ assert_eq ! ( outcome. entries[ 0 ] . funds, funds( 0 , 0 ) ) ;
1936+
1937+ let state = provider
1938+ . per_wallet
1939+ . get ( & WALLET )
1940+ . and_then ( |s| s. get ( & ACCOUNT ) )
1941+ . expect ( "account state present" ) ;
1942+ // In-memory `found` for the removed address is dropped.
1943+ assert ! ( !state. found. contains_key( & removed) ) ;
1944+ // The bijection is unchanged — pre-existing pairing survives.
1945+ assert_eq ! (
1946+ state. addresses. get_by_left( & 5u32 ) . copied( ) ,
1947+ Some ( conflicting)
1948+ ) ;
1949+ assert ! ( state. addresses. get_by_right( & removed) . is_none( ) ) ;
1950+ // The protected address's balance is untouched.
1951+ assert_eq ! ( state. found. get( & conflicting) . copied( ) , Some ( funds( 200 , 1 ) ) ) ;
1952+ }
1953+
1954+ /// A CREDIT (non-zero funds) that pool-resolves to an already-taken
1955+ /// derivation index must be dropped outright: neither applied to
1956+ /// `found` nor emitted, and the bijection untouched. Committing it
1957+ /// would either evict the existing pairing or persist a seed
1958+ /// `current_balances` can't round-trip.
1959+ #[ test]
1960+ fn commit_reconciliation_index_conflict_drops_credit ( ) {
1961+ use dash_sdk:: query_types:: AddressInfo ;
1962+
1963+ let owned = p2pkh ( 0x11 ) ;
1964+ let conflicting = p2pkh ( 0x77 ) ;
1965+ let mut provider = provider_with_one_funded_address ( owned, funds ( 700 , 3 ) ) ;
1966+ {
1967+ let state = provider
1968+ . per_wallet
1969+ . get_mut ( & WALLET )
1970+ . and_then ( |s| s. get_mut ( & ACCOUNT ) )
1971+ . expect ( "account state present" ) ;
1972+ state. insert_persisted_entry ( 5 , conflicting, funds ( 200 , 1 ) ) ;
1973+ }
1974+
1975+ // A credit for a fresh address that pool-resolves to the taken
1976+ // index 5.
1977+ let credited = p2pkh ( 0x33 ) ;
1978+ let credited_addr = PlatformAddress :: P2pkh ( [ 0x33 ; 20 ] ) ;
1979+ let mut pool_indexes = BTreeMap :: new ( ) ;
1980+ pool_indexes. insert ( credited, ( ACCOUNT , 5u32 ) ) ;
1981+
1982+ let mut address_infos = AddressInfos :: new ( ) ;
1983+ address_infos. insert (
1984+ credited_addr,
1985+ Some ( AddressInfo {
1986+ address : credited_addr,
1987+ nonce : 2 ,
1988+ balance : 5_000 ,
1989+ } ) ,
1990+ ) ;
1991+
1992+ let outcome = provider. commit_reconciliation ( & WALLET , & address_infos, & pool_indexes) ;
1993+
1994+ assert ! (
1995+ outcome. entries. is_empty( ) ,
1996+ "a credit under a conflicting index is dropped, not emitted"
1997+ ) ;
1998+ let state = provider
1999+ . per_wallet
2000+ . get ( & WALLET )
2001+ . and_then ( |s| s. get ( & ACCOUNT ) )
2002+ . expect ( "account state present" ) ;
2003+ // `found` never gained the conflicting credit.
2004+ assert ! ( !state. found. contains_key( & credited) ) ;
2005+ // Bijection untouched: index 5 still → conflicting, and the
2006+ // credited address was not inserted.
2007+ assert_eq ! (
2008+ state. addresses. get_by_left( & 5u32 ) . copied( ) ,
2009+ Some ( conflicting)
2010+ ) ;
2011+ assert ! ( state. addresses. get_by_right( & credited) . is_none( ) ) ;
2012+ assert_eq ! ( state. found. get( & conflicting) . copied( ) , Some ( funds( 200 , 1 ) ) ) ;
2013+ }
2014+
18612015 /// An entry identical to the committed seed is a no-op and is dropped
18622016 /// to avoid persister churn.
18632017 #[ test]
0 commit comments