Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
63a7f8e
fix(platform-wallet-storage): skip unreadable records in history repair
lklimek Sep 29, 2026
740bbdd
fix(platform-wallet-storage): keep history repairs reversible
lklimek Sep 29, 2026
318d874
refactor(platform-wallet-storage): freeze the V019 history repair
lklimek Sep 29, 2026
283acf7
fix(platform-wallet-storage): restore unconfirmed spend reservations …
lklimek Sep 29, 2026
05ade4e
fix(platform-wallet-storage): replay load history without an async br…
lklimek Sep 29, 2026
bc16c17
fix(swift-sdk): classify repaired direction like the Rust repair
lklimek Sep 29, 2026
e5fc327
test(swift-sdk): model asset-lock burns as OP_RETURN outputs
lklimek Sep 29, 2026
4a30a5b
fix(swift-sdk): scope per-wallet transaction amounts
lklimek Sep 29, 2026
8e5bcff
fix(swift-sdk): keep asset-lock debits when no input is linked yet
lklimek Sep 29, 2026
ec8b945
fix(swift-sdk): never block wallet restore on load-time accounting
lklimek Sep 29, 2026
b71f35a
chore: address low-severity PR #5150 review findings
lklimek Sep 29, 2026
c81c3d0
Merge remote-tracking branch 'origin/fix/pr-5126' into feat/sqlite-wa…
lklimek Sep 29, 2026
3380678
fix(platform-wallet-storage): fail on corrupt history unless a resync…
lklimek Sep 29, 2026
907eb88
merge: reuse wallet history restoration from PR 5208
lklimek Sep 29, 2026
10b15c9
feat(wallet-storage): restore complete persisted Core wallet snapshots
lklimek Sep 29, 2026
36cff4e
Merge branch 'fix/sqlite-asset-lock-reconciliation' into feat/sqlite-…
lklimek Sep 30, 2026
b27eb83
Merge branch 'fix/sqlite-asset-lock-reconciliation' into feat/sqlite-…
lklimek Sep 30, 2026
e733d9e
chore: merge fix/sqlite-asset-lock-reconciliation into feat/sqlite-wa…
lklimek Oct 12, 2026
728006c
docs(wallet-storage): drop comments that predate SQLite pool restore
lklimek Oct 12, 2026
94d6c32
test(wallet-storage): pin typed load failures and per-address accounting
lklimek Oct 12, 2026
958e930
fix(wallet-storage): check restored pool rows against their derivation
lklimek Oct 12, 2026
0c6e418
fix(wallet-storage): mark funded addresses used on restored pool entries
lklimek Oct 12, 2026
ef731e3
fix(wallet-storage): refuse pool rows filed under a pool the account …
lklimek Oct 12, 2026
c1bb8cd
perf(wallet-storage): restore pools without per-row and per-pool rework
lklimek Oct 12, 2026
3a3a013
test(wallet-storage): cover contact pools and the pool conflict guards
lklimek Oct 12, 2026
bc3f563
fix(wallet-storage): cache the pool restore statements
lklimek Oct 12, 2026
0c32443
fix(wallet-storage): budget pool derivation by stored rows, not by depth
lklimek Oct 12, 2026
80749c6
fix(wallet-storage): report stored reservations no restored pool take…
lklimek Oct 12, 2026
b1f0f10
test(wallet-storage): restore an operator key pool past its derived w…
lklimek Oct 12, 2026
eba7096
chore: merge fix/sqlite-asset-lock-reconciliation into feat/sqlite-wa…
lklimek Oct 12, 2026
921dbb8
test(wallet-storage): build the provider payload fixture for the pinn…
lklimek Oct 12, 2026
8c654db
fix(wallet-storage): refill the gap window of a pool restored as used
lklimek Oct 12, 2026
75bed6c
fix(wallet-storage): count a wallet's load incidents once across star…
lklimek Oct 12, 2026
85b5b45
fix(wallet-storage): log where a refused wallet or pool row sits
lklimek Oct 12, 2026
895bff9
feat(wallet-storage)!: mark LoadSite non-exhaustive
lklimek Oct 12, 2026
4b149b6
test(wallet-storage): restore the pools of an account registered as `…
lklimek Oct 12, 2026
8acdf9b
fix(wallet-storage): fail a wallet whose one-time funding key row is …
lklimek Oct 12, 2026
ce03450
test(wallet-storage): drop an unused import from the funding-key helper
lklimek Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
`serialization::dashcore::bls_pubkey` Serde adapter remains a deprecated alias.
Serialized encodings and consensus rules are unchanged. See [#5320](https://github.com/dashpay/platform/pull/5320).

- **Breaking (Rust API):** `platform_wallet_storage::LoadSite` gains
`PoolRowBeyondDerivationBudget` and `UnrestoredPoolReservation` and is now
`#[non_exhaustive]`; code that matches it exhaustively needs a wildcard arm.
Persisted data, FFI layouts and capability bits are unchanged.

## [5.0.0-beta.2](https://github.com/dashpay/platform/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-10-06)


Expand Down
52 changes: 48 additions & 4 deletions packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ use crate::sqlite::error::WalletStorageError;
/// for the same site — the two are deliberately separate: a UI layer wants
/// the prose, a log consumer wants the tag, and neither should have to
/// derive one from the other.
///
/// `#[non_exhaustive]`: a new kind of inconsistency gets its own variant, so
/// a match outside this crate needs a wildcard arm and keeps compiling when
/// one is added. Inside the crate [`as_str`](Self::as_str) and
/// [`explanation`](Self::explanation) still match every variant by name.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum LoadSite {
/// `core_sync_state.last_applied_chain_lock` failed to decode.
ChainLockBlob,
Expand Down Expand Up @@ -72,6 +78,16 @@ pub enum LoadSite {
UndecodableAddressScript,
/// One used address resolves to two different owning accounts.
UsedAddressOwnerConflict,
/// A stored address-pool row sits further above the rest of its pool
/// than the load will derive to check it, so it is not restored. A used
/// or reserved one-time funding key in that state fails the wallet
/// instead.
PoolRowBeyondDerivationBudget,
/// A stored address reservation was taken back by no restored pool: its
/// row matches no account of the wallet, or was left out above. Counted
/// per reservation. A one-time funding key in that state fails the
/// wallet instead.
UnrestoredPoolReservation,
Comment thread
lklimek marked this conversation as resolved.
/// An `identity_keys` row was unreadable or contradicted its columns.
IdentityKeyRow,
/// A `contacts` row was unreadable or contradicted its columns.
Expand Down Expand Up @@ -122,6 +138,8 @@ impl LoadSite {
Self::UnresolvedUtxoAddress => "unresolved_utxo_address",
Self::UndecodableAddressScript => "undecodable_address_script",
Self::UsedAddressOwnerConflict => "used_address_owner_conflict",
Self::PoolRowBeyondDerivationBudget => "pool_row_beyond_derivation_budget",
Self::UnrestoredPoolReservation => "unrestored_pool_reservation",
Self::MissingIdentityOwner => "missing_identity_owner",
Self::UnownedIdentityHasRegistrationIndex => "unowned_identity_has_registration_index",
Self::IdentityIndexCollision => "identity_index_collision",
Expand Down Expand Up @@ -160,16 +178,22 @@ impl LoadSite {
Self::WalletRehydration => {
"recovery mode: dropping one wallet that could not be rebuilt, keeping the rest of the file"
}
// The only two sites `note_degraded` ever reaches (see its
// callers) — never-fatal in either policy, so their prose
// describes an accepted-as-is degradation rather than a
// tolerated inconsistency.
// The sites `note_degraded` reaches (see its callers) —
// never-fatal in either policy, so their prose describes an
// accepted-as-is degradation rather than a tolerated
// inconsistency.
Self::OrphanedUtxoOwner => {
"load degraded: routing addresses from an unavailable owner to the first funds account"
}
Self::UnresolvedUtxoAddress => {
"load degraded: deferring addresses that did not resolve against the account xpub"
}
Self::PoolRowBeyondDerivationBudget => {
"load degraded: leaving out address-pool rows too far above their pool to be checked"
}
Self::UnrestoredPoolReservation => {
"load degraded: stored address reservations that no restored pool took back"
}
// Sites whose site tag plus the logged error already say
// everything a reader needs, so they share the generic line.
Self::ChainLockBlob
Expand Down Expand Up @@ -305,6 +329,10 @@ pub(crate) struct SiteCoords<'a> {
pub detail: Option<&'a dyn fmt::Debug>,
}

/// The per-site counts of a [`LoadCtx`] at one moment; see
/// [`LoadCtx::counts_checkpoint`].
pub(crate) struct CountsCheckpoint(BTreeMap<LoadSite, u32>);

/// Per-`load()` policy + counters, created on the loading thread's stack.
///
/// Not stored on the persister (which keeps only the resulting
Expand Down Expand Up @@ -438,6 +466,22 @@ impl LoadCtx {
)
}

/// The per-site counts as they stand, to come back to with
/// [`rewind_counts`](Self::rewind_counts).
pub(crate) fn counts_checkpoint(&self) -> CountsCheckpoint {
CountsCheckpoint(self.counts.borrow().clone())
}

/// Forget what was counted since `checkpoint`.
///
/// For a reader whose result is thrown away and built again: the second
/// pass meets the same rows, and what the first tolerated is not in the
/// state the load returns, so keeping both would report every incident
/// twice. Only the counts go back; the log keeps both passes.
pub(crate) fn rewind_counts(&self, checkpoint: CountsCheckpoint) {
*self.counts.borrow_mut() = checkpoint.0;
}

/// Attribute a whole-wallet loss to the wallet it belongs to.
///
/// Separate from [`tolerate_at`](Self::tolerate_at), which counts the
Expand Down
69 changes: 37 additions & 32 deletions packages/rs-platform-wallet-storage/src/sqlite/persister.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1310,11 +1310,10 @@ impl PlatformWalletPersistence for SqlitePersister {
// transaction. The current schema also has lossless token balances,
// invitations, account pools, tracked asset locks, and
// deferred-contact-crypto queue rows.
// Do NOT attest WALLET_RESTORE (and therefore not provider restore):
// token balances and the DashPay overlay have no load readers, so a
// full restore remains lossy. Shielded viewing keys are native when
// compiled in; notes, nullifiers, and sync state use ShieldedStore.
// WALLET_RESTORE covers the Core snapshot; auxiliary token, profile,
// invitation, and deferred-contact readback remains separate.
let capabilities = PersistenceCapabilities::ATOMIC_CHANGESETS
.union(PersistenceCapabilities::WALLET_RESTORE)
.union(PersistenceCapabilities::INVITATIONS)
.union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES)
.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE)
Expand Down Expand Up @@ -1691,10 +1690,15 @@ fn load_one_wallet(
let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
.map_err(WalletStorageError::from)
.map_err(PersistenceError::from)?;
let counted_before = ctx.counts_checkpoint();
let (mut state, sweeps) = load_wallet_snapshot(&tx, wallet_id, ctx)?;
if !sweeps.is_empty() {
schema::core_state::apply_replay_sweeps(&tx, &wallet_id, sweeps)
.map_err(PersistenceError::from)?;
// The snapshot above is thrown away, and what it tolerated with it:
// the rebuild meets the same rows and counts them for the state that
// is returned.
ctx.rewind_counts(counted_before);
// Rebuild from the repaired projection, including released materialized inputs.
let (repaired, remaining) = load_wallet_snapshot(&tx, wallet_id, ctx)?;
if !remaining.is_empty() {
Expand Down Expand Up @@ -1847,12 +1851,7 @@ fn load_wallet_snapshot(
key_wallet::account::account_collection::AccountCollection::new(),
)
} else {
build_wallet(network, wallet_id, &account_manifest).map_err(|e| {
PersistenceError::backend(format!(
"watch-only wallet rebuild failed for {}: {e}",
hex::encode(wallet_id)
))
})?
build_wallet(network, wallet_id, &account_manifest).map_err(PersistenceError::from)?
};
// TODO(insert-wallet-id-recompute): confirm whether key_wallet's
// insert_wallet recomputes wallet_id — see PR's existing Deferred
Expand All @@ -1864,9 +1863,23 @@ fn load_wallet_snapshot(
&wallet,
birth_height,
);
// Pools come back first, so the used-address projection below marks the
// restored entries too: a stored row can still say "available" for an
// address that has since been funded. The platform-node reader stays
// ahead of `restore_pools` because it rewrites its entries' state from
// `used` alone, and `restore_pools` overlays their reservations.
if account_manifest
.provider
.iter()
.any(|entry| entry.account_type == key_wallet::account::AccountType::ProviderPlatformKeys)
{
restore_provider_platform_node_pool(&mut wallet_info, conn, &wallet_id, network, ctx)
.map_err(PersistenceError::from)?;
}
schema::core_pool::restore_pools(conn, &wallet_id, &wallet, &mut wallet_info, ctx)
.map_err(PersistenceError::from)?;
Comment thread
lklimek marked this conversation as resolved.
// Provider key-material accounts hold no funds, so only the ECDSA
// half feeds the UTXO/balance projection here. The platform-node
// pre-derived-key pool is restored separately below.
// half feeds the UTXO/balance projection here.
apply_persisted_core_state(
&mut wallet_info,
&account_manifest.ecdsa,
Expand All @@ -1875,25 +1888,7 @@ fn load_wallet_snapshot(
&used_core_addresses,
ctx,
)
.map_err(|e| {
PersistenceError::backend(format!(
"core-state rehydration failed for {}: {e}",
hex::encode(wallet_id)
))
})?;
if account_manifest
.provider
.iter()
.any(|entry| entry.account_type == key_wallet::account::AccountType::ProviderPlatformKeys)
{
restore_provider_platform_node_pool(&mut wallet_info, conn, &wallet_id, network, ctx)
.map_err(|e| {
PersistenceError::backend(format!(
"platform-node pool rehydration failed for {}: {e}",
hex::encode(wallet_id)
))
})?;
}
.map_err(PersistenceError::from)?;
let mut wallet = wallet;
let sweeps = restore_recorded_transactions(
&mut wallet_info,
Expand Down Expand Up @@ -1946,7 +1941,17 @@ fn record_wallet_degradation(
wallet_id,
cause: original.to_string(),
},
)?;
)
// Strict: the caller propagates its original error, and a typed cause
// rarely names the wallet it came from, so this is where it is named.
.inspect_err(|_| {
tracing::error!(
wallet_id = %hex::encode(wallet_id),
error_kind = cause,
error = %original,
"strict load: a wallet could not be rebuilt, so the load fails"
);
})?;
ctx.note_wallet_degraded(wallet_id, cause);
Ok(())
}
Expand Down
20 changes: 14 additions & 6 deletions packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ pub(crate) fn restore_provider_platform_node_pool(
return Ok(());
}

// TODO(#4188): `reserved_at` is persisted but deliberately not consumed here;
// restoring it requires widening `provider_accounts::insert_platform_node_pool_entry`.
// `reserved_at` is not consumed here: `core_pool::restore_pools` runs next
// and overlays each row's reservation state on the entries inserted below.
for (index, script_bytes, public_key, used) in entries {
let PublicKeyType::EdDSA(public_key) = public_key else {
return Err(WalletStorageError::blob_decode(
Expand Down Expand Up @@ -1032,7 +1032,7 @@ fn owning_account_of(account: &ManagedCoreFundsAccount) -> OwningAccount {
/// rescan to re-warm — generous enough to cover any realistic per-account
/// derivation depth. The common (single funds account) path terminates at
/// the true high-water mark well before this and never reaches the cap.
const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000;
pub(crate) const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000;

/// Soft threshold past which a single chain's discovery scan is treated as
/// abnormally deep and worth a `tracing::warn!`. Real funds chains anchor
Expand Down Expand Up @@ -1293,16 +1293,24 @@ fn extend_pools_for_restored_addresses(
// reaches 50 and derives idx 45). A single mark-then-refill pass
// would leave that address in the pool with `used = false`, handing
// a previously-used address back out as fresh. Terminates: each
// round marks at least one new address from the finite restored set
// (`mark_used` returns `true` only on an unused→used flip).
// round after the first marks at least one new address from the
// finite restored set (`mark_used` returns `true` only on an
// unused→used flip).
//
// A pool restored from its stored rows enters with those entries
// already used, so no flip announces them. Its window is refilled
// once regardless, or the addresses past the stored rows would stay
// underived: unobserved, and a funded one among them never marked.
let mut restored_used = pool.highest_used.is_some();
loop {
let mut marked_any = false;
for addr in restored_addresses {
if pool.mark_used(addr) {
marked_any = true;
}
}
if !marked_any {
let refill_restored = std::mem::take(&mut restored_used);
if !marked_any && !refill_restored {
break;
}
// Refill the gap window past the deepest used index (needs the
Expand Down
Loading
Loading