Skip to content

Commit eae653e

Browse files
fix(platform-wallet): data-integrity follow-ups from the #3990 sync review (#4008)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e8b9da2 commit eae653e

8 files changed

Lines changed: 580 additions & 96 deletions

File tree

packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -132,21 +132,45 @@ impl IdentityWallet {
132132
}
133133
let identity = registered_identity;
134134

135-
// Step 3: Add the identity to the local manager (with its HD
136-
// index) so subsequent operations route through it.
135+
// Step 3 (best-effort): add the identity to the local manager
136+
// (with its HD index) so subsequent operations route through it.
137+
//
138+
// Platform has ALREADY accepted the registration, so a local
139+
// persistence failure must NOT propagate as `Err` — that would
140+
// suppress the `(identity, address_infos)` return the caller
141+
// needs to reconcile the spent funding-address balances, leaving
142+
// them stale even though the identity exists on chain. A missed
143+
// local add self-heals on the next identity re-sync. This mirrors
144+
// the sibling `transfer_credits_to_addresses` / top-up flows,
145+
// which likewise treat post-acceptance local bookkeeping as
146+
// best-effort.
137147
{
138148
let mut wm = self.wallet_manager.write().await;
139-
let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| {
140-
crate::error::PlatformWalletError::WalletNotFound(
141-
"Wallet info not found in wallet manager".to_string(),
142-
)
143-
})?;
144-
info.identity_manager.add_identity(
145-
identity.clone(),
146-
identity_index,
147-
self.wallet_id,
148-
&self.persister,
149-
)?;
149+
match wm.get_wallet_info_mut(&self.wallet_id) {
150+
Some(info) => {
151+
if let Err(e) = info.identity_manager.add_identity(
152+
identity.clone(),
153+
identity_index,
154+
self.wallet_id,
155+
&self.persister,
156+
) {
157+
tracing::warn!(
158+
error = %e,
159+
identity_id = %identity.id(),
160+
"register_from_addresses: identity registered on Platform but \
161+
local add_identity failed; returning the registered identity \
162+
anyway so address balances can reconcile"
163+
);
164+
}
165+
}
166+
None => {
167+
tracing::warn!(
168+
identity_id = %identity.id(),
169+
"register_from_addresses: identity registered on Platform but wallet \
170+
info was not found locally; skipping local persistence"
171+
);
172+
}
173+
}
150174
}
151175

152176
// The spent platform-address balances are reconciled by the

packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,11 +307,40 @@ impl PlatformAddressWallet {
307307
// forcing the next pass to full-scan-reconcile — the automated
308308
// equivalent of the manual Sync-tab "Clear" + "Sync Now".
309309
let credited_outputs = super::credited_outputs_set(addresses.keys());
310-
let cs = self
311-
.reconcile_address_infos(&address_infos, &credited_outputs, "fund from asset lock")
310+
// Use the persistence-reporting variant: marking the lock
311+
// `Consumed` below is irreversible, so it MUST be gated on the
312+
// reconciled balances actually reaching disk. `persisted` is
313+
// false ONLY when the in-memory balances were updated but the
314+
// durable write failed — exactly the case where a Consumed lock
315+
// would pair with stale rows and under-budget the next spend
316+
// after a restart.
317+
let (cs, persisted) = self
318+
.reconcile_address_infos_with_persistence(
319+
&address_infos,
320+
&credited_outputs,
321+
"fund from asset lock",
322+
)
312323
.await;
313324

314325
if let Some(out_point) = tracked_out_point {
326+
if !persisted {
327+
// The proof-attested balances were applied in memory but
328+
// did not reach disk. Leave the lock non-Consumed: it
329+
// stays in the Resumable Funding list, and a user Resume
330+
// gets Platform's deterministic "lock already consumed"
331+
// rejection — the same benign recovery path as a failed
332+
// consume below — while the next platform-address sync
333+
// repairs the stale rows. Consuming here would strand the
334+
// lock as Consumed over durable balances that under-report
335+
// the credit.
336+
tracing::error!(
337+
outpoint = %out_point,
338+
"skipping consume_asset_lock: the reconciled balance changeset \
339+
was not durably stored; the lock stays non-Consumed (Resumable) \
340+
rather than pairing a Consumed lock with stale balance rows on disk"
341+
);
342+
return Ok(cs);
343+
}
315344
// Platform DID accept the top-up — propagating an Err
316345
// here would misreport the protocol outcome, since the
317346
// caller's recipient(s) already have credits attested

packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs

Lines changed: 172 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)