Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
50cab4c
test(platform-wallet-storage): capture populated-V001 fixture (WS-B B0)
lklimek Jul 2, 2026
61c292c
feat(platform-wallet-storage): add V002 unified migration (WS-B B1)
lklimek Jul 2, 2026
c9603d6
chore(platform-wallet-storage): drop committed WAL side-files, gitign…
lklimek Jul 2, 2026
e663a35
test(platform-wallet-storage): pin schema content + freeze retired na…
lklimek Jul 2, 2026
503b251
feat(platform-wallet-storage): real account_index + pool-row writer (…
lklimek Jul 2, 2026
f06f9ff
feat(platform-wallet-storage): bump meta_data_versions inside the flu…
lklimek Jul 2, 2026
00499e0
feat(platform-wallet-storage): rotate store-generation on restore (WS…
lklimek Jul 2, 2026
d514d01
feat(platform-wallet-storage): read used-set verbatim from pool rows …
lklimek Jul 2, 2026
c44d684
docs(platform-wallet): correct account_address_pools storage semantic…
lklimek Jul 2, 2026
1d2612e
test(platform-wallet-storage): migration execution against V001 fixtu…
lklimek Jul 2, 2026
a7c1aa6
chore(platform-wallet-storage): QA pass — clippy/secrets/prepared-stm…
lklimek Jul 2, 2026
6dd08d4
fix(platform-wallet-storage): re-arm exhaustive touched_domains guard…
lklimek Jul 2, 2026
ab12991
fix(platform-wallet-storage): union pool + utxo used-sets in reader (…
lklimek Jul 2, 2026
4f95f46
test(platform-wallet-storage): prove interrupted-migration recovery (…
lklimek Jul 2, 2026
e3eb645
test(platform-wallet-storage): cross-wallet cascade/isolation for V00…
lklimek Jul 2, 2026
a511bde
docs(platform-wallet-storage): drop ephemeral plan-task IDs from test…
lklimek Jul 2, 2026
0614fc4
Merge branch 'feat/platform-wallet-storage-rehydration' into feat/396…
lklimek Jul 3, 2026
a1a23cd
feat(platform-wallet-storage): manifest integrity checksum on account…
lklimek Jul 3, 2026
e145b9f
Merge commit '0614fc4c7a' into feat/3968-manifest-integrity-checksum
lklimek Jul 3, 2026
e8b71d8
fix(platform-wallet-storage): reconcile V003 checksum work with snaps…
lklimek Jul 3, 2026
d22d5c5
fix(platform-wallet-storage): populate core_wallet_info in SQLite reh…
lklimek Jul 3, 2026
aa11d17
fix(platform-wallet-storage): harden core address-pool storage
lklimek Jul 3, 2026
c0f17da
fix(platform-wallet-storage): rotate store generation within restore …
lklimek Jul 3, 2026
579b620
merge: origin/feat/3968-snapshot-redirect into feat/3968-manifest-int…
lklimek Jul 3, 2026
2020cf3
merge: origin/feat/platform-wallet-storage-rehydration into feat/3968…
lklimek Jul 6, 2026
5914e23
Merge branch 'feat/platform-wallet-storage-rehydration' into feat/396…
lklimek Jul 6, 2026
69d85dd
Merge branch 'feat/platform-wallet-storage-rehydration' into feat/396…
lklimek Jul 6, 2026
06e768b
Merge branch 'feat/platform-wallet-storage-rehydration' into feat/396…
lklimek Jul 8, 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
11 changes: 11 additions & 0 deletions packages/rs-platform-wallet-ffi/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ pub const LOAD_SKIP_REASON_DECODE_ERROR: u32 = 102;
/// persisted row (wallet_id/network differ, or its account set diverges
/// from the row's account manifest) — a wrong-row snapshot.
pub const LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH: u32 = 103;
/// `reason_code`: a persisted account-manifest row failed its integrity
/// checksum (`SHA-256(wallet_id ‖ account_xpub_bytes)` mismatch — a row
/// bound to the wrong wallet or a blob mutated in place).
pub const LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH: u32 = 104;
/// `reason_code`: an unrecognized `CorruptKind` — forward-compat
/// fallback until this crate maps a newly added corrupt-row family.
pub const LOAD_SKIP_REASON_CORRUPT_OTHER: u32 = 199;
Expand All @@ -214,6 +218,7 @@ pub struct SkippedWalletFFI {
/// [`LOAD_SKIP_REASON_MALFORMED_XPUB`] (101),
/// [`LOAD_SKIP_REASON_DECODE_ERROR`] (102),
/// [`LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH`] (103),
/// [`LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH`] (104),
/// [`LOAD_SKIP_REASON_CORRUPT_OTHER`] (199),
/// [`LOAD_SKIP_REASON_OTHER`] (200), or
/// [`LOAD_SKIP_REASON_ALREADY_REGISTERED`] (300). No secret material
Expand Down Expand Up @@ -252,6 +257,7 @@ fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 {
CorruptKind::MalformedXpub => LOAD_SKIP_REASON_MALFORMED_XPUB,
CorruptKind::SnapshotIdentityMismatch => LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH,
CorruptKind::DecodeError(_) => LOAD_SKIP_REASON_DECODE_ERROR,
CorruptKind::ManifestIntegrityMismatch => LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH,
// `CorruptKind` is #[non_exhaustive]; a future variant maps to a
// generic corrupt-row code until this mapping is extended.
_ => LOAD_SKIP_REASON_CORRUPT_OTHER,
Expand Down Expand Up @@ -604,6 +610,7 @@ mod tests {
assert_eq!(LOAD_SKIP_REASON_MALFORMED_XPUB, 101);
assert_eq!(LOAD_SKIP_REASON_DECODE_ERROR, 102);
assert_eq!(LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH, 103);
assert_eq!(LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH, 104);
assert_eq!(LOAD_SKIP_REASON_CORRUPT_OTHER, 199);
assert_eq!(LOAD_SKIP_REASON_OTHER, 200);
}
Expand All @@ -630,6 +637,10 @@ mod tests {
skip_reason_code(&corrupt(CorruptKind::DecodeError("boom".into()))),
LOAD_SKIP_REASON_DECODE_ERROR
);
assert_eq!(
skip_reason_code(&corrupt(CorruptKind::ManifestIntegrityMismatch)),
LOAD_SKIP_REASON_MANIFEST_INTEGRITY_MISMATCH
);
}

#[test]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//! Additive V004 migration: per-row manifest integrity checksum (#3968).
//!
//! Adds a nullable `checksum BLOB` to `account_registrations`. Each row's
//! checksum is `SHA-256(wallet_id ‖ account_xpub_bytes)`, bound by the writer
//! and by a one-time app-side backfill in `SqlitePersister::open`. SQLite has
//! no SHA-256 builtin (and no constant-literal default can carry a per-row
//! digest), so the column is added nullable at DDL time and made effectively
//! non-null at rest by that backfill; verify-at-load treats a surviving NULL
//! on a V004+ store as tamper-evidence.
//!
//! Additive-only: V001/V002/V003 stay byte-identical so refinery's
//! applied-migration checksum chain for versions 1, 2, and 3 never diverges on
//! an existing store. `max_supported_version()` lifts 3 to 4 automatically (the
//! value is derived from the embedded list's `MAX(version)`).

pub fn migration() -> String {
"ALTER TABLE account_registrations ADD COLUMN checksum BLOB;\n".to_string()
}
12 changes: 12 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,16 @@ pub enum WalletStorageError {
)]
AccountRegistrationEntryMismatch,

/// An `account_registrations` row's stored `checksum` did not match a
/// recompute of `SHA-256(wallet_id ‖ account_xpub_bytes)` — or was NULL on
/// a V004+ store, which the `open()` backfill guarantees never survives.
/// Tamper-evidence for the Risk-6 class (a manifest row bound to the wrong
/// `wallet_id`, or a blob mutated in place). Unlike the other
/// mismatch variants this is caught at `load` and converted to a per-wallet
/// skip rather than aborting the batch.
#[error("account_registrations manifest integrity checksum mismatch")]
ManifestIntegrityMismatch,

/// An `asset_locks` row's typed-column `(outpoint, account_index)`
/// disagreed with the lifecycle blob's. Rejected at decode time rather
/// than mis-bucketing the lock under the wrong account.
Expand Down Expand Up @@ -375,6 +385,7 @@ impl WalletStorageError {
| Self::IdentityKeyEntryMismatch
| Self::IdentityEntryIdMismatch
| Self::AccountRegistrationEntryMismatch
| Self::ManifestIntegrityMismatch
| Self::AssetLockEntryMismatch { .. }
| Self::BlobTooLarge { .. }
| Self::IntegerOverflow { .. } => false,
Expand Down Expand Up @@ -452,6 +463,7 @@ impl WalletStorageError {
Self::IdentityKeyEntryMismatch => "identity_key_entry_mismatch",
Self::IdentityEntryIdMismatch => "identity_entry_id_mismatch",
Self::AccountRegistrationEntryMismatch => "account_registration_entry_mismatch",
Self::ManifestIntegrityMismatch => "manifest_integrity_mismatch",
Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch",
Self::BlobTooLarge { .. } => "blob_too_large",
Self::IntegerOverflow { .. } => "integer_overflow",
Expand Down
24 changes: 24 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/persister.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use rusqlite::{Connection, OptionalExtension};
use platform_wallet::changeset::{
ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence,
};
use platform_wallet::manager::load_outcome::{CorruptKind, SkipReason};
use platform_wallet::wallet::platform_wallet::WalletId;

use crate::sqlite::backup::{self, BackupKind};
Expand Down Expand Up @@ -234,6 +235,12 @@ impl SqlitePersister {

let _report = crate::sqlite::migrations::run_for_open(&mut conn)?;

// SQLite cannot compute the per-row SHA-256 manifest checksum in pure
// SQL, so V004's column lands nullable and any row a forward migration
// left without one is filled here — once, before load() ever verifies
// it. Idempotent; a no-op on a store whose rows already carry one.
schema::accounts::backfill_missing_checksums(&mut conn)?;

// Claim the path LAST so a failed open leaves no stale claim;
// canonicalize so symlinks / `.`-segments key the same as a
// sibling open would.
Expand Down Expand Up @@ -928,6 +935,23 @@ impl PlatformWalletPersistence for SqlitePersister {
))
})?;

// Manifest integrity is a per-wallet SKIP, not a batch abort: a
// tampered / mis-bound row fails this one wallet and records it in
// `skipped`, while every other read error below stays fail-hard.
match schema::accounts::verify_manifest_checksums(&conn, &wallet_id) {
Ok(()) => {}
Err(WalletStorageError::ManifestIntegrityMismatch) => {
state.skipped.push((
wallet_id,
SkipReason::CorruptPersistedRow {
kind: CorruptKind::ManifestIntegrityMismatch,
},
));
continue;
}
Err(other) => return Err(PersistenceError::from(other)),
}

let account_manifest =
schema::accounts::load_state(&conn, &wallet_id).map_err(PersistenceError::from)?;
let core_state = schema::core_state::load_state(&conn, &wallet_id, network)
Expand Down
Loading
Loading