Skip to content
Open
Show file tree
Hide file tree
Changes from 24 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 @@ -187,6 +187,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 @@ -208,6 +212,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), or
/// [`LOAD_SKIP_REASON_OTHER`] (200). No secret material is ever
/// carried.
Expand Down Expand Up @@ -241,6 +246,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 @@ -589,6 +595,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 @@ -615,6 +622,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
6 changes: 6 additions & 0 deletions packages/rs-platform-wallet-storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,9 @@ kv = ["sqlite"]
__test-helpers = ["sqlite"]
# e2e tests that drive the #3692 manager-apply path; enabled in the integrated stack (dash-evo-tool).
rehydration-apply = []
# Pass-through to `platform-wallet/shielded` so the feature-gated
# `PlatformWalletChangeSet::shielded` field is visible to the exhaustive
# `versions::touched_domains` destructure (the R8 forgotten-domain guard).
# Storage persists no shielded state itself; this only aligns visibility so
# an added always-on field stays a compile error.
shielded = ["platform-wallet/shielded"]
80 changes: 80 additions & 0 deletions packages/rs-platform-wallet-storage/migrations/V002__unified.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//! Unified additive migration for `platform-wallet-storage` (#3968).
//!
//! Additive-only: V001 stays byte-identical so refinery's applied-migration
//! checksum for version 1 never diverges on an existing store. V002 lifts
//! `max_supported_version()` from 1 to 2 automatically (the value is derived
//! from the embedded list) and lands three concerns in one migration event:
//!
//! - `core_address_pool` — per-index address-pool rows with a `used` flag,
//! the first-class row store that replaces `core_utxos` script-derivation
//! for the address-reuse guard. `account_type` and `pool_type` are both in
//! the primary key: `account_type` so two accounts that collapse to the same
//! `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` and
//! `ProviderVotingKeys`, both `0, 0`) never overwrite each other, and
//! `pool_type` so an External (receive) and Internal (change) pool never
//! collide at the same `address_index`. `script` (the address'
//! `script_pubkey`) is stored so the reader returns used addresses verbatim
//! and the UTXO writer can attribute an outpoint to its owning account, both
//! without re-deriving.
//! - `meta_data_versions` — per-`(wallet_id, domain)` monotonic `seq`
//! bumped inside the flush transaction, the cache-invalidation keystone.
//! No FK (a domain row may be written before its typed parent syncs,
//! mirroring the `meta_*` tables); a soft-cascade trigger reaps rows on
//! wallet delete.
//! - `meta_store_generation` — a single-row store-generation token,
//! initialized with `randomblob(16)` so the rendered SQL stays deterministic (the
//! content fingerprint pins the text, the runtime value is unique per
//! store). Regenerated on restore.
//!
//! No MAC column ships here — manifest authentication is deferred out of
//! this workstream (dev-plan §7).

pub fn migration() -> String {
"\
CREATE TABLE core_address_pool (
wallet_id BLOB NOT NULL,
account_type TEXT NOT NULL,
account_index INTEGER NOT NULL,
key_class INTEGER NOT NULL DEFAULT 0,
pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)),
address_index INTEGER NOT NULL,
script BLOB NOT NULL,
used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)),
PRIMARY KEY (wallet_id, account_type, account_index, key_class, pool_type, address_index),
FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);

CREATE INDEX idx_core_address_pool_used
ON core_address_pool(wallet_id, used);

-- The UTXO writer attributes an outpoint to its owning account by matching
-- the outpoint's script against a pool row.
CREATE INDEX idx_core_address_pool_script
ON core_address_pool(wallet_id, script);

CREATE TABLE meta_data_versions (
wallet_id BLOB NOT NULL,
domain TEXT NOT NULL,
seq INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (wallet_id, domain)
);

-- Soft-cascade reap, matching the meta_* tables: no FK (a domain may be
-- bumped before its typed parent exists), so a trigger clears rows when
-- the owning wallet is deleted.
CREATE TRIGGER cascade_meta_data_versions_on_wallet_delete
AFTER DELETE ON wallets
FOR EACH ROW
BEGIN
DELETE FROM meta_data_versions WHERE wallet_id = OLD.wallet_id;
END;

CREATE TABLE meta_store_generation (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0),
generation BLOB NOT NULL
);

INSERT INTO meta_store_generation (id, generation) VALUES (0, randomblob(16));
"
.to_string()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//! Additive V003 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 V003+ store as tamper-evidence.
//!
//! Additive-only: V001/V002 stay byte-identical so refinery's
//! applied-migration checksum chain for versions 1 and 2 never diverges on an
//! existing store. `max_supported_version()` lifts 2 to 3 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()
}
63 changes: 44 additions & 19 deletions packages/rs-platform-wallet-storage/src/sqlite/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,17 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> {
///
/// Validation runs against the source and again against the STAGED bytes,
/// under a SQLite-native `BEGIN EXCLUSIVE` on `dest_db_path` that blocks
/// every other SQLite peer (which advisory flock could not). The staged
/// temp is `persist`-ed as an atomic rename only after all gates pass, and
/// that rename is the commit point: if it fails, the live DB and its WAL/SHM
/// siblings are left untouched, so a failed restore never strands the old DB
/// without its WAL-committed state. The now-stale WAL/SHM siblings are
/// unlinked only AFTER the swap succeeds (so a leftover `-wal` can't shadow
/// the restored DB); the parent dir is fsynced afterward. See the numbered
/// steps in the body for the per-phase rationale.
/// every other SQLite peer (which advisory flock could not). The
/// store-generation token is rotated INTO the staged temp before the swap,
/// so the single commit point brings in the restored bytes and the fresh
/// token together — a peer never observes restored content carrying the
/// source's stale token. The staged temp is `persist`-ed as an atomic rename
/// only after all gates pass, and that rename is the commit point: if it
/// fails, the live DB and its WAL/SHM siblings are left untouched, so a failed
/// restore never strands the old DB without its WAL-committed state. The
/// now-stale WAL/SHM siblings are unlinked only AFTER the swap succeeds (so a
/// leftover `-wal` can't shadow the restored DB); the parent dir is fsynced
/// afterward. See the numbered steps in the body for the per-phase rationale.
///
/// # Lock-release-before-rename trade-off
///
Expand Down Expand Up @@ -224,7 +227,27 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
crate::sqlite::migrations::assert_schema_history_well_formed(&staged)?;
}

// 5. chmod 0o600 on the temp BEFORE persist so the destination
// 5. Regenerate the store-generation token INTO the staged temp, before
// the atomic rename, so the single commit point (step 8) swaps in the
// restored bytes and the rotated token together — there is no window
// where restored content is observable with the source's stale token.
// The staged DB is switched to DELETE journaling first so the UPDATE
// lands in the main file with no `-wal` frames stranded outside the
// rename; the reopened destination is forced back to its configured
// journal mode on its next open. A pre-V002 backup has no generation
// table; `regenerate_generation` is a no-op there and the token is
// (re)seeded on its later migration to V002.
{
let conn =
crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadWrite)?;
conn.pragma_update(None, "journal_mode", "DELETE")?;
crate::sqlite::schema::versions::regenerate_generation(&conn)?;
drop(conn);
// Durably flush the regenerated token before the rename commits it.
tmp.as_file().sync_all()?;
}

// 6. chmod 0o600 on the temp BEFORE persist so the destination
// inherits owner-only mode via the rename (post-persist chmod could
// fail with the new DB already live).
#[cfg(unix)]
Expand All @@ -234,7 +257,7 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}

// 6. Release the EXCLUSIVE lock before the rename/unlinks: on Windows /
// 7. Release the EXCLUSIVE lock before the rename/unlinks: on Windows /
// some FUSE mounts `remove_file` on a still-open file returns
// `PermissionDenied`, and the rename window wants a clean close (see
// lock-release trade-off above).
Expand All @@ -243,18 +266,20 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
drop(conn);
}

// 7. Persist the staged DB atomically over the destination FIRST. The
// atomic rename is the commit point: if it fails (disk full, EXDEV,
// perms) the live DB and its WAL/SHM siblings are left untouched, so a
// failed restore can never strand the old DB without its WAL-committed
// state. Sibling cleanup (step 8) runs only once the swap has succeeded.
// 8. Persist the staged DB atomically over the destination. The atomic
// rename is the single commit point: it swaps in both the restored
// bytes and the rotated generation token together. If it fails (disk
// full, EXDEV, perms) the live DB and its WAL/SHM siblings are left
// untouched, so a failed restore can never strand the old DB without
// its WAL-committed state. Sibling cleanup (step 9) runs only once the
// swap has succeeded.
tmp.persist(dest_db_path)
.map_err(|e| WalletStorageError::Io(e.error))?;

// 8. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover
// 9. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover
// `-wal` can't shadow the restored DB on the next open. Sibling paths
// use `OsString::push` so non-UTF-8 bytes round-trip; `NotFound` is a
// silent no-op. The lock conn was dropped in step 6 for cross-platform
// silent no-op. The lock conn was dropped in step 7 for cross-platform
// unlink semantics.
if let Some(file_name) = dest_db_path.file_name() {
for ext in ["-wal", "-shm"] {
Expand All @@ -269,10 +294,10 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet
}
}

// 9. Make the rename + unlink dentry updates durable.
// 10. Make the rename + unlink dentry updates durable.
fsync_parent_dir(dest_db_path)?;

// 10. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm).
// 11. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm).
apply_secure_permissions(dest_db_path)?;
Ok(())
}
Expand Down
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 V003+ 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
46 changes: 46 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,52 @@ pub fn embedded_migrations_fingerprint() -> [u8; 32] {
hasher.finalize().into()
}

/// SHA-256 over `(version, name, rendered SQL)` of every embedded migration
/// in version order. Unlike [`embedded_migrations_fingerprint`] this is
/// content-level: it pins each migration's SQL body, so an in-place DDL edit
/// (e.g. renaming a table inside a same-named file) breaks the golden test.
/// This is the guard the D0 schema freeze relies on; the identity-only
/// fingerprint cannot catch a same-name body edit.
///
/// The SQL *text* is deterministic even where a value is generated at run
/// time (`randomblob(16)`): the literal string is hashed, not the runtime
/// bytes.
#[cfg(any(test, feature = "__test-helpers"))]
pub fn embedded_migrations_sql_fingerprint() -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut migrations = migrations::runner().get_migrations().clone();
migrations.sort_by_key(|m| m.version());
let mut hasher = Sha256::new();
for m in &migrations {
hasher.update((m.version() as u32).to_be_bytes());
hasher.update([0u8]);
hasher.update(m.name().as_bytes());
hasher.update([0u8]);
let sql = m
.sql()
.expect("embedded migrations always carry rendered SQL");
hasher.update(sql.as_bytes());
hasher.update([0u8]);
}
hasher.finalize().into()
}

/// Rendered SQL of every embedded migration, in version order. Used by the
/// schema-freeze grep guard to scan for retired table names.
#[cfg(any(test, feature = "__test-helpers"))]
pub fn embedded_migrations_sql() -> Vec<String> {
let mut migrations = migrations::runner().get_migrations().clone();
migrations.sort_by_key(|m| m.version());
migrations
.iter()
.map(|m| {
m.sql()
.expect("embedded migrations always carry rendered SQL")
.to_string()
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading