diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f04c71c92ae..af787939bb2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -52,6 +52,7 @@ jobs: release wasm-sdk platform-wallet + wallet-storage swift-example-app kotlin-sdk kotlin-example-app diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 27e9d99906d..b62f70f986a 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -166,6 +166,7 @@ jobs: --package platform-value \ --package rs-dapi \ --package platform-wallet \ + --package platform-wallet-storage \ --package rs-sdk-ffi \ --package platform-wallet-ffi \ --package rs-dapi-client \ @@ -339,6 +340,7 @@ jobs: --package platform-value \ --package rs-dapi \ --package platform-wallet \ + --package platform-wallet-storage \ --package rs-sdk-ffi \ --package platform-wallet-ffi \ --package rs-dapi-client \ diff --git a/Cargo.lock b/Cargo.lock index 7d9ef80d0db..7a84c1d09d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "assert_matches" version = "1.5.0" @@ -552,7 +567,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -561,6 +585,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -778,6 +808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -1858,6 +1889,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -2303,7 +2340,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex-automata", "regex-syntax", ] @@ -2350,6 +2387,16 @@ dependencies = [ "flate2", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2373,6 +2420,15 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2679,7 +2735,7 @@ dependencies = [ "memuse", "rand 0.8.6", "rand_core 0.6.4", - "rand_xorshift", + "rand_xorshift 0.3.0", "subtle", ] @@ -4295,6 +4351,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4875,6 +4937,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "platform-wallet-storage" +version = "3.1.0-dev.5" +dependencies = [ + "assert_cmd", + "bincode", + "chrono", + "clap", + "dash-sdk", + "dashcore", + "dpp", + "filetime", + "hex", + "humantime", + "key-wallet", + "platform-wallet", + "platform-wallet-storage", + "predicates", + "proptest", + "refinery", + "rusqlite", + "serde", + "serde_json", + "serial_test", + "sha2", + "static_assertions", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "tracing-test", +] + [[package]] name = "plotters" version = "0.3.7" @@ -4973,7 +5069,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", "predicates-core", + "regex", ] [[package]] @@ -5077,6 +5177,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.11.1", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift 0.4.0", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -5243,6 +5362,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick_cache" version = "0.6.22" @@ -5423,6 +5548,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -5517,6 +5651,47 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "refinery" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee5133e5b207e5703c2a4a9dc9bd8c8f2cc74c4ac04ca5510acaa907012c77ac" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a2a96d959c9b5b5da78e965bfdb1363b365bf5e84531a67d0eee827a702a3" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "siphasher", + "thiserror 2.0.18", + "time", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56c2e960c8e47c7c5c30ad334afea8b5502da796a59e34d640d6239d876d924" +dependencies = [ + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -6129,6 +6304,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -7679,6 +7866,27 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-wasm" version = "0.2.1" @@ -7752,6 +7960,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61faa33dc26b2851a37da5390a1a4cac015887b1e97ecd77ce7b4f987431de9f" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -7954,6 +8168,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 6b5d7750a92..5f5426afda4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "packages/rs-dash-event-bus", "packages/rs-platform-wallet", "packages/rs-platform-wallet-ffi", + "packages/rs-platform-wallet-storage", "packages/rs-platform-encryption", "packages/wasm-sdk", "packages/rs-unified-sdk-ffi", diff --git a/Dockerfile b/Dockerfile index d4c787b7fc3..30cdef82cf9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -399,6 +399,7 @@ COPY --parents \ packages/rs-context-provider \ packages/rs-sdk-trusted-context-provider \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/wasm-dpp \ packages/wasm-dpp2 \ packages/wasm-drive-verify \ @@ -505,6 +506,7 @@ COPY --parents \ packages/rs-context-provider \ packages/rs-sdk-trusted-context-provider \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/wasm-dpp \ packages/wasm-dpp2 \ packages/wasm-drive-verify \ @@ -860,6 +862,7 @@ COPY --parents \ packages/rs-sdk-ffi \ packages/rs-unified-sdk-ffi \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/check-features \ packages/dash-platform-balance-checker \ packages/wasm-sdk \ diff --git a/packages/rs-platform-wallet-ffi/src/identity_sync.rs b/packages/rs-platform-wallet-ffi/src/identity_sync.rs index 36bbdfb7115..8e4399b5520 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_sync.rs @@ -14,6 +14,7 @@ use std::time::Duration; +use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet::{IdentityTokenSyncInfo, IdentityTokenSyncState}; use crate::error::*; @@ -314,25 +315,47 @@ unsafe fn read_token_ids(ptr: *const u8, count: usize) -> Option PlatformWalletFFIResult { check_ptr!(identity_id_ptr); + check_ptr!(wallet_id_ptr); let mut id_bytes = [0u8; 32]; std::ptr::copy_nonoverlapping(identity_id_ptr, id_bytes.as_mut_ptr(), 32); let identity_id = dpp::prelude::Identifier::from(id_bytes); + let mut wallet_bytes: WalletId = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_ptr, wallet_bytes.as_mut_ptr(), 32); + // Reject the all-zero sentinel explicitly: this entry point exists + // to propagate a real parent wallet, and callers that genuinely + // want the orphan registration must not reach the wallet-aware + // path. + if wallet_bytes.iter().all(|b| *b == 0) { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "wallet_id is the all-zero sentinel; pass a real parent wallet id", + ); + } + let Some(token_ids) = read_token_ids(token_ids_ptr, token_ids_count) else { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorNullPointer, @@ -342,7 +365,10 @@ pub unsafe extern "C" fn platform_wallet_manager_identity_sync_register_identity let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { let mgr = manager.identity_sync_arc(); - runtime().block_on(async move { mgr.register_identity(identity_id, token_ids).await }); + runtime().block_on(async move { + mgr.register_identity_with_wallet(identity_id, Some(wallet_bytes), token_ids) + .await + }); }); unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 072a0ea50ab..75cc5eef276 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1281,6 +1281,11 @@ impl PlatformWalletPersistence for FFIPersister { fn load(&self) -> Result { // If Swift hasn't wired up `on_load_wallet_list_fn` there's // nothing to restore — treat as a fresh client. + // TODO(CODE-012): enforce paired (on_load_wallet_list_fn, + // on_load_wallet_list_free_fn) at registration time per + // thepastaclaw review on PR #3625. Deferred to a separate + // FFI-hardening PR — this gap pre-existed on v3.1-dev and is + // not introduced by #3625. let Some(load_cb) = self.callbacks.on_load_wallet_list_fn else { return Ok(ClientStartState::default()); }; @@ -1538,6 +1543,9 @@ impl PlatformWalletPersistence for FFIPersister { }; use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + // TODO(CODE-013): same as CODE-012 — enforce paired + // (on_get_core_tx_record_fn, on_get_core_tx_record_free_fn) at + // registration time. Deferred to FFI-hardening PR. let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else { return Ok(None); }; @@ -3190,7 +3198,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result Result { let standard_tag = StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag) .ok_or_else(|| { - PersistenceError::Backend(format!( + PersistenceError::backend(format!( "AccountSpecFFI(Standard) carries unknown standard_tag byte {}", spec.standard_tag )) @@ -3254,7 +3262,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result { - return Err(PersistenceError::Backend(format!( + return Err(PersistenceError::backend(format!( "AccountTypeTagFFI {:?} is no longer mappable to a key-wallet AccountType after the upstream event-bus refactor (TODO(events))", type_tag ))); diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 3f152059c87..d907a11065e 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -307,15 +307,28 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( // *and* the per-network sync-coordination registry; we hand it // to `bind_shielded` so the wallet reuses the shared store and // self-registers its viewing keys for the coordinator-driven - // sync loop. + // sync loop. We also pull the manager's shared shielded + // start-state snapshot here (CODE-017) so the wallet's restore + // step skips its own `persister.load()` — when several wallets + // bind at startup, every call reuses the same cached `Arc`. let lookup = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { runtime().block_on(async { let wallet = manager.get_wallet(&wallet_id).await; let coordinator = manager.shielded_coordinator().await; - (wallet, coordinator) + let cached_snapshot = manager.cached_persisted_shielded().await; + (wallet, coordinator, cached_snapshot) }) }); - let (wallet_arc, coordinator) = unwrap_option_or_return!(lookup); + let (wallet_arc, coordinator, cached_snapshot) = unwrap_option_or_return!(lookup); + let cached_snapshot = match cached_snapshot { + Ok(snap) => snap, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("failed to load cached shielded snapshot: {e}"), + ); + } + }; let wallet_arc = match wallet_arc { Some(w) => w, None => { @@ -335,10 +348,11 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( } }; - if let Err(e) = runtime().block_on(wallet_arc.bind_shielded( + if let Err(e) = runtime().block_on(wallet_arc.bind_shielded_with_snapshot( seed.as_ref(), accounts.as_slice(), &coordinator, + cached_snapshot, )) { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml new file mode 100644 index 00000000000..14c1bce76b3 --- /dev/null +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -0,0 +1,131 @@ +[package] +name = "platform-wallet-storage" +version.workspace = true +rust-version.workspace = true +edition = "2021" +authors = ["Dash Core Team"] +license = "MIT" +description = "Storage backends for platform-wallet: SQLite persistence (today) and a future SecretStore submodule" + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "platform-wallet-storage" +path = "src/bin/platform-wallet-storage.rs" +required-features = ["cli"] + +[dependencies] +# Truly cross-cutting deps (always on regardless of features). +thiserror = "1" +tracing = "0.1" +hex = "0.4" + +# SQLite-backed persister deps (gated by the `sqlite` feature). +# `platform-wallet` types are reachable through the `sqlite` submodule +# only; without the feature the bare crate ships no items that mention +# them, so the wallet/serde graph stays out of the build (CODE-020). +# `dpp` types reach the persister via `IdentityPublicKey` (identity_keys +# writer), `AssetLockProof` (asset_locks writer) and `Identifier` +# (dashpay writer). `dash-sdk` is here for the `AddressFunds` re-export +# in `schema/platform_addrs.rs`. Feature set mirrors sibling +# `rs-platform-wallet` so the resolver picks identical hashes. +platform-wallet = { path = "../rs-platform-wallet", features = [ + "serde", +], optional = true } +serde = { version = "1", features = ["derive"], optional = true } +key-wallet = { workspace = true, optional = true } +dashcore = { workspace = true, optional = true } +dpp = { path = "../rs-dpp", optional = true } +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", +], optional = true } +rusqlite = { version = "0.38", features = [ + "bundled", + "backup", + "blob", + "hooks", + "trace", +], optional = true } +refinery = { version = "0.9", default-features = false, features = [ + "rusqlite", +], optional = true } +# bincode 2 is required directly: we encode `dpp::IdentityPublicKey` +# (which derives bincode 2 `Encode`/`Decode`) and decode +# `dpp::AssetLockProof` from the asset-lock blob column. +bincode = { version = "2", optional = true } +tempfile = { version = "3", optional = true } +chrono = { version = "0.4", default-features = false, features = [ + "clock", +], optional = true } +sha2 = { version = "0.10", optional = true } + +# CLI deps (gated by the `cli` feature) +clap = { version = "4", features = ["derive"], optional = true } +humantime = { version = "2", optional = true } +serde_json = { version = "1", optional = true } +tracing-subscriber = { version = "0.3", features = [ + "env-filter", +], optional = true } + +[dev-dependencies] +proptest = "1" +assert_cmd = "2" +predicates = "3" +static_assertions = "1" +filetime = "0.2" +tracing-test = { version = "0.2", features = ["no-env-filter"] } +serial_test = "3" +platform-wallet-storage = { path = ".", features = ["sqlite", "cli", "__test-helpers"] } +# `round_trip_consumer.rs` constructs a real `PlatformWalletManager` +# (consumer) against a real `SqlitePersister` (this crate's impl) so +# every consumer↔persister contract drift becomes a CI failure (CODE-008 +# / T-024). The manager needs `dash-sdk::SdkBuilder::new_mock().build()` +# (gated behind `mocks`) and `platform-wallet` requires `wallet` on the +# SDK transitively. Tokio is needed directly so `#[tokio::test]` +# resolves the macro by name. +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", + "wallet", + "mocks", +] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[features] +default = ["sqlite", "cli"] +# SQLite-backed persister (`platform_wallet_storage::sqlite`). +sqlite = [ + "dep:platform-wallet", + "dep:serde", + "dep:key-wallet", + "dep:dashcore", + "dep:dpp", + "dep:dash-sdk", + "dep:rusqlite", + "dep:refinery", + "dep:bincode", + "dep:tempfile", + "dep:chrono", + "dep:sha2", +] +# Maintenance CLI binary. Requires `sqlite` because the only subcommands +# in scope today operate on the SQLite persister. +cli = [ + "sqlite", + "dep:clap", + "dep:humantime", + "dep:serde_json", + "dep:tracing-subscriber", +] +# Future `SecretStore` submodule. Slot is reserved; the module is not +# implemented in this build — enabling the feature today is a no-op +# beyond a `// pub mod secrets;` marker in `src/lib.rs`. +secrets = [] +# Exposes `lock_conn_for_test` / `config_for_test` accessors on +# `SqlitePersister` so this crate's own integration tests can probe +# the write connection. The double-underscore prefix follows Cargo's +# convention for "MUST NOT enable from downstream" features +# (https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2). +__test-helpers = ["sqlite"] diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md new file mode 100644 index 00000000000..850e5179ccc --- /dev/null +++ b/packages/rs-platform-wallet-storage/README.md @@ -0,0 +1,166 @@ +# platform-wallet-storage + +Storage backends for the +[`platform-wallet`](../rs-platform-wallet) crate. Today this crate +ships a SQLite-backed implementation of `PlatformWalletPersistence` +under [`sqlite`](src/sqlite/) plus a maintenance CLI; the crate is +structured so a future `SecretStore` (currently sketched in +[`SECRETS.md`](./SECRETS.md)) can land as a sibling submodule under +[`secrets`](src/) without a crate split. + +## At a glance + +- One `.db` file holds many wallets — every per-wallet row carries a + `wallet_id BLOB` primary-key component. +- Schema migrations are append-only Rust files under `migrations/`, + applied via [`refinery`](https://github.com/rust-db/refinery) on every + `open`. +- Online backup uses `rusqlite::backup::Backup::run_to_completion` — + safe under a concurrent writer. +- **No private-key material.** See [`SECRETS.md`](./SECRETS.md). +- `Send + Sync`; usable behind `Arc`. +- Writers use `prepare_cached` so each INSERT/UPDATE is parsed once + per `Connection` lifetime; subsequent flushes hit the cache. + +## Flush semantics + +`flush()` and `Immediate`-mode `store()` succeed-or-restore: on a +transient SQLite failure (`SQLITE_BUSY` / `SQLITE_LOCKED`) the +buffered changeset is merged back into the per-wallet buffer (LWW +with anything `store()`-d during the failed transaction) and the +call returns a `PersistenceError::Backend { kind: Transient, source }` +whose source carries the marker `flush failed transiently`. +**Retry the call** — do not discard state. Fatal failures (integrity +check, encode error, mutex poison, …) return `kind: Fatal` (or +`kind: Constraint` for SQL constraint violations) and drop the buffer. + +The full classification lives on +[`WalletStorageError::is_transient`](src/sqlite/error.rs) and the +companion [`WalletStorageError::persistence_kind`](src/sqlite/error.rs) +that selects the trait-side kind. The `source` field is a +`Box` over the original `WalletStorageError` +— operators can walk `Error::source()` for the full typed chain; +the outer `Display` carries the variant marker + hex wallet id so +production-log greps still work. + +## load() reconstruction + +`SqlitePersister::load()` returns the base `ClientStartState` +(plain struct, two slots — no `#[non_exhaustive]`): + +| Slot | Reader | Status | +|---|---|---| +| `platform_addresses` | `schema::platform_addrs::load_all` (a `wallet_meta::list_ids` → `load_state` loop) | populated | +| `wallets` | — | empty pending upstream `Wallet::from_persisted` | + +The `identities` / `contacts` / `asset_locks` per-area readers exist +as hardened dormant helpers (`schema::::load_state`) but are not +wired into `load()` — `ClientStartState` carries no slot for them. + +Loading is **fail-hard**: any row that fails to decode, or a stored +`wallet_id` that is not exactly 32 bytes, aborts the whole call with a +typed [`WalletStorageError`](src/sqlite/error.rs) +(`BincodeDecode` / `BlobDecode` / `InvalidWalletIdLength`). There is no +corruption tolerance, no per-row skip, and no partial `Ok` — a corrupt +database surfaces as an error rather than silently losing rows. + +The summary `tracing::info!` carries `wallets_seen`, +`addresses_loaded`, `wallets_rehydrated`, and +`wallets_pending_rehydration` (the count of wallets that *would* be +rehydrated once upstream provides `Wallet::from_persisted`). + +## Library usage + +```rust,no_run +use std::sync::Arc; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +let config = SqlitePersisterConfig::new("/tmp/wallets.db"); +let persister: Arc = + Arc::new(SqlitePersister::open(config)?); +# Ok::<_, platform_wallet_storage::WalletStorageError>(()) +``` + +The same types are also reachable via their canonical submodule path — +`platform_wallet_storage::sqlite::SqlitePersister` — for callers that +want to be explicit about the backend. + +`SqlitePersisterConfig::new(path)` produces sensible defaults: +`Immediate` flush, 5 s busy timeout, WAL journal, `NORMAL` +synchronous, and an auto-backup dir at `/backups/auto/`. + +## CLI + +```text +platform-wallet-storage --db migrate [--no-auto-backup] +platform-wallet-storage --db backup --out +platform-wallet-storage --db restore --from --yes +platform-wallet-storage --db prune --in [--keep-last N] [--max-age 30d] +platform-wallet-storage --db inspect [--wallet-id ] [--format text|tsv|json] +``` + +Destructive subcommands (`restore`) REQUIRE `--yes` — invoking them +without it exits 2 with a usage error. `--no-auto-backup` opts out of +the pre-restore (or pre-migration) auto-backup; it is the supported way +to disable auto-backup. The historical sentinel `--auto-backup-dir ""` +also disables it but is **deprecated** and emits a warning — prefer the +explicit `--no-auto-backup` flag. + +Wallet removal is a library-only API +([`SqlitePersister::delete_wallet`] / `delete_wallet_skip_backup`); +no CLI subcommand exposes it. + +Logging: `-v` / `-vv` / `-vvv` enable `info` / `debug` / `trace` +respectively on stderr; `-q` suppresses non-error output. + +Exit codes: `0` success, `1` runtime error, `2` usage error, `3` +validation failure (e.g. corrupt backup source). + +## Operational notes + +**Restore exclusion.** `restore` opens a short-lived writer connection +on the destination DB and holds a SQLite-native `BEGIN EXCLUSIVE` +transaction across the entire restore body. This interlocks with every +other SQLite peer — sibling `SqlitePersister` handles, bare +`rusqlite::Connection` instances, the CLI — so concurrent writes back +off via SQLite's `busy_timeout` instead of racing the atomic swap. If a +peer holds the destination busy for longer than the timeout, `restore` +returns `WalletStorageError::RestoreDestinationLocked`. The lock conn is +released BEFORE the rename so SQLite's file handle on the old inode goes +away before the new DB takes its place. + +**Manual-mode drop diagnostic.** `SqlitePersister` configured with +[`FlushMode::Manual`] emits a `tracing::error!` on drop if the buffer +still holds uncommitted writes (with `dirty_wallets` and `total_fields` +fields). The crate does NOT auto-flush from `Drop` — call +[`SqlitePersister::commit_writes`] (or per-wallet `flush`) before drop +to make Manual-mode writes durable. + +## Cargo features + +| Feature | Default | What it brings | +|---|---|---| +| `sqlite` | yes | SQLite persister (`platform_wallet_storage::sqlite`) and all of its native deps (`rusqlite`, `refinery`, `dpp`, `dash-sdk`, `key-wallet`, etc.) | +| `cli` | yes | Maintenance binary `platform-wallet-storage`. Implies `sqlite`. | +| `secrets` | no | Reserved for the future `SecretStore` submodule. No code lands today. | +| `__test-helpers` | no | Crate-private `lock_conn_for_test` / `config_for_test` accessors. The double-underscore prefix follows Cargo's "do not enable from downstream" convention; the methods are also `#[doc(hidden)]`. | + +`cargo build -p platform-wallet-storage --no-default-features` builds +the crate with neither the SQLite backend nor the CLI compiled in. +The resulting library has no public surface today; the build mode +exists to support a future split where one cargo target wants only +the secrets feature. + +## Schema + +See [`migrations/V001__initial.rs`](./migrations/V001__initial.rs) for +the canonical schema. It is hand-written `CREATE TABLE … PRIMARY KEY … +FOREIGN KEY …` SQL with native `ON DELETE CASCADE` constraints; INSERT, +DELETE-cascade, and UPDATE re-parenting are all enforced by SQLite +itself. Foreign-key enforcement is enabled and read-back-asserted on +every connection open via the `open_conn` choke-point — if the linked +SQLite cannot honor `PRAGMA foreign_keys`, open fails hard. The single +remaining trigger clears `core_utxos.spent_in_txid` to NULL on +transaction delete (a native composite `SET NULL` would null the +NOT-NULL `wallet_id` column too). diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md new file mode 100644 index 00000000000..8871f0f3963 --- /dev/null +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -0,0 +1,67 @@ +# Private-key boundary + +The SQLite persister in `platform-wallet-storage::sqlite` is the +canonical persistence backend for the data carried by +`PlatformWalletPersistence` — UTXOs, identities, identity public keys, +contacts, asset locks, token balances, DashPay overlays, address-pool +snapshots. **None of that is secret material.** + +Mnemonics, seeds, raw private keys, and any other long-lived signing +material live exclusively on the client side (iOS Keychain, Android +Keystore, OS keyring, encrypted file vault). They are re-derived as +needed via the wallet's BIP-32/BIP-39 plumbing and never touch the +SQLite file the persister writes. + +## Future `secrets` submodule sketch + +This crate is structured so the `SecretStore` trait can land as a +submodule (`platform_wallet_storage::secrets`) gated behind a `secrets` +Cargo feature, sharing the crate-level error type and config +conventions. The module slot is reserved in `src/lib.rs` with a +commented-out `pub mod secrets;` line; the feature flag exists today +but flips no code. + +```rust +trait SecretStore: Send + Sync { + fn put(&self, wallet_id: WalletId, label: &str, bytes: &[u8]) -> Result<()>; + fn get(&self, wallet_id: WalletId, label: &str) -> Result>>; + fn delete(&self, wallet_id: WalletId, label: &str) -> Result<()>; +} +``` + +Reference backends to plan for: + +- `KeyringStore` (default) — OS-native keyring; recoverable across + reinstalls when the keyring is. +- `EncryptedFileStore` — Argon2id + XChaCha20-Poly1305 over a passphrase. +- `MemoryStore` — tests only. + +## What the SQLite backend WILL refuse to store + +The `identity_keys` table is for **public** material only — DPP +public keys, public-key hashes, optional DIP-9 derivation breadcrumbs. +If a sub-changeset ever gains a `private_key_bytes`-style field, the +trait conversation must reopen: the persister boundary stays +secret-free. + +## Audit hooks + +- **`tests/secrets_scan.rs`**: greps every file under + `src/sqlite/schema/` and `migrations/` for the substrings `private`, + `mnemonic`, `seed`, `xpriv`, `secret`. A new column, blob field, or + comment that uses any of those words breaks the test — forcing the + author to either rename, or add their phrase to the file's + allow-list with a rationale. The future `src/secrets/` directory is + exempt by design. +- NFR-4 / TC-082 (`tests/sqlite_persist_roundtrip.rs::tc082_no_box_dyn_error_in_src`): + all public method signatures use concrete error types + (`SqlitePersisterError`, `PersistenceError`) — never + `Box` — so a future leak is caught by `grep`. + +## Backup retention and secrets + +Manual / auto backups are byte-for-byte copies of the live DB. They +inherit the same "no secrets in the file" invariant. Operators may +still want to encrypt backups at rest using a file-system level tool +(GnuPG, age, encfs); this crate does not do that for them and never +ships SQLCipher. diff --git a/packages/rs-platform-wallet-storage/build.rs b/packages/rs-platform-wallet-storage/build.rs new file mode 100644 index 00000000000..34796d8f623 --- /dev/null +++ b/packages/rs-platform-wallet-storage/build.rs @@ -0,0 +1,21 @@ +//! Re-run the build whenever any file under `migrations/` changes. +//! +//! `refinery::embed_migrations!("./migrations")` is a proc-macro +//! evaluated at compile time. Cargo does not, by default, track +//! file-system reads inside proc macros — adding or editing a file +//! under `migrations/` will not trigger a rebuild of crates that +//! depend on this one until a source file in `src/` is touched. +//! Emitting `rerun-if-changed` directives below closes that gap. + +use std::path::Path; + +fn main() { + let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let migrations_dir = Path::new(&manifest).join("migrations"); + println!("cargo:rerun-if-changed={}", migrations_dir.display()); + if let Ok(entries) = std::fs::read_dir(&migrations_dir) { + for entry in entries.flatten() { + println!("cargo:rerun-if-changed={}", entry.path().display()); + } + } +} diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs new file mode 100644 index 00000000000..863a7b9cd2b --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -0,0 +1,236 @@ +//! Initial schema for `platform-wallet-storage`. +//! +//! Hand-written `CREATE TABLE … PRIMARY KEY … FOREIGN KEY …` SQL run +//! through refinery. SQLite has no `ALTER TABLE ADD CONSTRAINT`, so the +//! FK clause must live inside `CREATE TABLE`; that requirement is why +//! the schema is emitted as explicit DDL rather than a query-builder. +//! +//! Every per-wallet table carries `wallet_id BLOB` in (or as all of) +//! its primary key plus a native `FOREIGN KEY (wallet_id) REFERENCES +//! wallet_metadata(wallet_id) ON DELETE CASCADE`. `identity_keys` and +//! `dashpay_profiles` additionally key into `identities(wallet_id, +//! identity_id)`. The one relationship that stays a trigger is +//! `core_utxos.spent_in_txid` clearing to NULL on transaction delete — +//! a native composite `ON DELETE SET NULL` would null the NOT-NULL +//! `wallet_id` too (SQLite nulls all FK columns), so the single-column +//! trigger preserves the intended semantics. +//! +//! Foreign-key enforcement is per-connection and is switched on (and +//! read back) at every connection open via `open_conn` +//! (`src/sqlite/conn.rs`). + +/// The whole schema as one statement batch. refinery runs the returned +/// string verbatim. +const SCHEMA_SQL: &str = "\ +CREATE TABLE wallet_metadata ( + wallet_id BLOB NOT NULL PRIMARY KEY, + network TEXT NOT NULL, + birth_height INTEGER NOT NULL +); + +CREATE TABLE account_registrations ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL, + account_index INTEGER NOT NULL, + account_xpub_bytes BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_type, account_index), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE account_address_pools ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL, + account_index INTEGER NOT NULL, + pool_type TEXT NOT NULL, + snapshot_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_type, account_index, pool_type), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE core_transactions ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + height INTEGER, + block_hash BLOB, + block_time INTEGER, + finalized INTEGER NOT NULL, + record_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE INDEX idx_core_transactions_height ON core_transactions(wallet_id, height); + +CREATE TABLE core_utxos ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + value INTEGER NOT NULL, + script BLOB NOT NULL, + height INTEGER, + account_index INTEGER NOT NULL, + spent INTEGER NOT NULL, + spent_in_txid BLOB, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE INDEX idx_core_utxos_spent ON core_utxos(wallet_id, spent); + +-- `spent_in_txid` clears to NULL when its transaction row is deleted. +-- This can't be a native composite `ON DELETE SET NULL` FK to +-- `core_transactions(wallet_id, txid)`: SQLite nulls EVERY column of a +-- composite FK on SET NULL, and `wallet_id` is NOT NULL, so the delete +-- would fail. The single-column trigger nulls only `spent_in_txid`, +-- matching the lazy semantics the prior schema relied on. +CREATE TRIGGER setnull_core_utxos_on_tx_delete +AFTER DELETE ON core_transactions +FOR EACH ROW +BEGIN + UPDATE core_utxos SET spent_in_txid = NULL + WHERE wallet_id = OLD.wallet_id AND spent_in_txid = OLD.txid; +END; + +CREATE TABLE core_instant_locks ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + islock_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE core_derived_addresses ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL, + account_index INTEGER NOT NULL, + address TEXT NOT NULL, + derivation_path TEXT NOT NULL, + used INTEGER NOT NULL, + PRIMARY KEY (wallet_id, account_type, address), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE INDEX idx_core_derived_addresses_addr ON core_derived_addresses(wallet_id, address); + +CREATE TABLE core_sync_state ( + wallet_id BLOB NOT NULL PRIMARY KEY, + last_processed_height INTEGER, + synced_height INTEGER, + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE identities ( + wallet_id BLOB NOT NULL, + wallet_index INTEGER, + identity_id BLOB NOT NULL, + entry_blob BLOB NOT NULL, + tombstoned INTEGER NOT NULL, + PRIMARY KEY (wallet_id, identity_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE identity_keys ( + wallet_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + key_id INTEGER NOT NULL, + public_key_blob BLOB NOT NULL, + public_key_hash BLOB NOT NULL, + derivation_blob BLOB, + PRIMARY KEY (wallet_id, identity_id, key_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE, + FOREIGN KEY (wallet_id, identity_id) + REFERENCES identities(wallet_id, identity_id) ON DELETE CASCADE +); + +CREATE INDEX idx_identity_keys_wallet_identity ON identity_keys(wallet_id, identity_id); + +CREATE TABLE contacts_sent ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + recipient_id BLOB NOT NULL, + entry_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, owner_id, recipient_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE contacts_recv ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + sender_id BLOB NOT NULL, + entry_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, owner_id, sender_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE contacts_established ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + entry_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, owner_id, contact_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE platform_addresses ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL, + address_index INTEGER NOT NULL, + address BLOB NOT NULL, + balance INTEGER NOT NULL, + nonce INTEGER NOT NULL, + PRIMARY KEY (wallet_id, address), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE platform_address_sync ( + wallet_id BLOB NOT NULL PRIMARY KEY, + sync_height INTEGER NOT NULL, + sync_timestamp INTEGER NOT NULL, + last_known_recent_block INTEGER NOT NULL, + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE asset_locks ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + status TEXT NOT NULL, + account_index INTEGER NOT NULL, + identity_index INTEGER NOT NULL, + amount_duffs INTEGER NOT NULL, + lifecycle_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE token_balances ( + wallet_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + token_id BLOB NOT NULL, + balance INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (wallet_id, identity_id, token_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +CREATE TABLE dashpay_profiles ( + wallet_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + profile_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, identity_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE, + FOREIGN KEY (wallet_id, identity_id) + REFERENCES identities(wallet_id, identity_id) ON DELETE CASCADE +); + +CREATE TABLE dashpay_payments_overlay ( + wallet_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + payment_id TEXT NOT NULL, + overlay_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, identity_id, payment_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); +"; + +pub fn migration() -> String { + SCHEMA_SQL.to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V002__cascade_only_identity_refs.rs b/packages/rs-platform-wallet-storage/migrations/V002__cascade_only_identity_refs.rs new file mode 100644 index 00000000000..20ed002f373 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V002__cascade_only_identity_refs.rs @@ -0,0 +1,149 @@ +//! V002 — cascade-only references on identity-owned tables (CODE-002). +//! +//! V001 keyed every identity-owned table by `(wallet_id, identity_id, +//! ...)` and bolted a direct `FOREIGN KEY (wallet_id) REFERENCES +//! wallet_metadata` onto each. That double-link forced consumers +//! (`identity_sync`) to invent a sentinel `WalletId` when they only +//! held an identity id, breaking FK enforcement and silently dropping +//! token-balance writes. +//! +//! V002 drops the direct `wallet_id` reference from every identity-owned +//! table. The cascade chain is now: +//! +//! ```text +//! wallet_metadata +//! └─(ON DELETE CASCADE)─ identities (wallet_id NULLable) +//! └─(ON DELETE CASCADE)─ identity_keys +//! └─(ON DELETE CASCADE)─ dashpay_profiles +//! └─(ON DELETE CASCADE)─ dashpay_payments_overlay +//! └─(ON DELETE CASCADE)─ token_balances +//! ``` +//! +//! `identities.wallet_id` is now NULL-allowed so identity-only flows +//! (no parent wallet, e.g. the identity-sync manager populating rows +//! before any wallet is registered) work without a sentinel. +//! +//! Forward-only. Dev DBs that carry the V001 sentinel rows +//! (`wallet_id = X'00…00'` in `token_balances`) are refused: a +//! temp-table CHECK constraint named `sentinel_count` fails when the +//! row count is non-zero, aborting the migration transaction. +//! `SqlitePersister::open` re-classifies that raw rusqlite error into +//! the typed +//! [`crate::sqlite::error::WalletStorageError::MigrationRequiresManualCleanup`] +//! variant so the operator gets a non-cryptic message. The operator +//! must manually drop the sentinel rows before re-running migrations: +//! +//! ```text +//! DELETE FROM token_balances +//! WHERE wallet_id = X'0000000000000000000000000000000000000000000000000000000000000000'; +//! ``` + +/// Public entry point invoked by `refinery::embed_migrations!`. +/// +/// refinery runs the returned SQL verbatim inside one transaction. +/// The leading guard `SELECT` aborts the transaction with a typed +/// error message when sentinel rows exist; everything after the guard +/// is skipped automatically by SQLite when the abort fires. +pub fn migration() -> String { + // SQLite has no top-level RAISE() outside trigger bodies, so the + // sentinel guard rides a CHECK constraint on a temp table: + // inserting a non-zero count fails the CHECK and aborts the + // implicit refinery transaction. The CHECK's failure surfaces as + // a `SQLITE_CONSTRAINT_CHECK` error whose message names the temp + // table (`_v002_sentinel_rows_must_be_zero`); the persister side + // re-classifies that into + // `WalletStorageError::MigrationRequiresManualCleanup`. + // + // Operators clear the legacy rows manually before re-running: + // DELETE FROM token_balances + // WHERE wallet_id = X'0000000000000000000000000000000000000000000000000000000000000000'; + let guard = "\ +-- V002 pre-flight: refuse if any legacy sentinel wallet_id rows exist. +CREATE TEMP TABLE _v002_sentinel_rows_must_be_zero ( + sentinel_count INTEGER NOT NULL CHECK (sentinel_count = 0) +); +INSERT INTO _v002_sentinel_rows_must_be_zero (sentinel_count) \ + SELECT COUNT(*) FROM token_balances \ + WHERE wallet_id = X'0000000000000000000000000000000000000000000000000000000000000000'; +DROP TABLE _v002_sentinel_rows_must_be_zero; +"; + // The migration runner (`sqlite::migrations::run_for_open`) + // disables `PRAGMA foreign_keys` before BEGIN so the schema rewrite + // below does not trigger `ON DELETE CASCADE` on child tables when + // their parent is dropped. The pragma is re-enabled (with + // read-back assertion) right after the migration completes. + let body = "\ +-- ----- identities: nullable wallet_id, PK = identity_id ----- +CREATE TABLE identities_v2 ( + identity_id BLOB NOT NULL PRIMARY KEY, + wallet_id BLOB, + wallet_index INTEGER, + entry_blob BLOB NOT NULL, + tombstoned INTEGER NOT NULL, + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); +INSERT INTO identities_v2 (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) + SELECT identity_id, wallet_id, wallet_index, entry_blob, tombstoned FROM identities; +DROP TABLE identities; +ALTER TABLE identities_v2 RENAME TO identities; +CREATE INDEX idx_identities_wallet ON identities(wallet_id); + +-- ----- identity_keys: drop wallet_id, FK identity_id only ----- +CREATE TABLE identity_keys_v2 ( + identity_id BLOB NOT NULL, + key_id INTEGER NOT NULL, + public_key_blob BLOB NOT NULL, + public_key_hash BLOB NOT NULL, + derivation_blob BLOB, + PRIMARY KEY (identity_id, key_id), + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +INSERT INTO identity_keys_v2 (identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) + SELECT identity_id, key_id, public_key_blob, public_key_hash, derivation_blob FROM identity_keys; +DROP TABLE identity_keys; +ALTER TABLE identity_keys_v2 RENAME TO identity_keys; +CREATE INDEX idx_identity_keys_identity ON identity_keys(identity_id); + +-- ----- dashpay_profiles: drop wallet_id, FK identity_id only ----- +CREATE TABLE dashpay_profiles_v2 ( + identity_id BLOB NOT NULL PRIMARY KEY, + profile_blob BLOB NOT NULL, + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +INSERT INTO dashpay_profiles_v2 (identity_id, profile_blob) + SELECT identity_id, profile_blob FROM dashpay_profiles; +DROP TABLE dashpay_profiles; +ALTER TABLE dashpay_profiles_v2 RENAME TO dashpay_profiles; + +-- ----- dashpay_payments_overlay: drop wallet_id, FK identity_id only ----- +CREATE TABLE dashpay_payments_overlay_v2 ( + identity_id BLOB NOT NULL, + payment_id TEXT NOT NULL, + overlay_blob BLOB NOT NULL, + PRIMARY KEY (identity_id, payment_id), + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +INSERT INTO dashpay_payments_overlay_v2 (identity_id, payment_id, overlay_blob) + SELECT identity_id, payment_id, overlay_blob FROM dashpay_payments_overlay; +DROP TABLE dashpay_payments_overlay; +ALTER TABLE dashpay_payments_overlay_v2 RENAME TO dashpay_payments_overlay; + +-- ----- token_balances: drop wallet_id, FK identity_id only ----- +CREATE TABLE token_balances_v2 ( + identity_id BLOB NOT NULL, + token_id BLOB NOT NULL, + balance INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (identity_id, token_id), + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +INSERT INTO token_balances_v2 (identity_id, token_id, balance, updated_at) + SELECT identity_id, token_id, balance, updated_at FROM token_balances; +DROP TABLE token_balances; +ALTER TABLE token_balances_v2 RENAME TO token_balances; +"; + let mut sql = String::with_capacity(guard.len() + body.len()); + sql.push_str(guard); + sql.push_str(body); + sql +} diff --git a/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs b/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs new file mode 100644 index 00000000000..b9999f70062 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs @@ -0,0 +1,425 @@ +//! CLI front-end for the SQLite persister. +//! +//! Output convention: stdout = data; stderr = diagnostics + error +//! messages (lower-cased, no trailing period, single line). + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::Duration; + +use clap::{Args, Parser, Subcommand}; + +use platform_wallet_storage::{ + default_auto_backup_dir, AutoBackupOperation, RetentionPolicy, SqlitePersister, + SqlitePersisterConfig, WalletStorageError, +}; + +#[derive(Debug, Parser)] +#[command( + name = "platform-wallet-storage", + version, + about = "Maintenance CLI for the SQLite-backed platform wallet persister" +)] +struct Cli { + /// Path to the SQLite database file. + #[arg(long, value_name = "PATH", global = true)] + db: Option, + /// Auto-backup directory. The empty-string ("") form is + /// **deprecated** as a way to disable auto-backup — use the + /// subcommand flag `--no-auto-backup` instead (supported by + /// `migrate` and `restore`). The empty-string form still parses for + /// one release; a deprecation warning is logged when used. + #[arg(long, value_name = "PATH", global = true)] + auto_backup_dir: Option, + /// Increase log verbosity (stderr). Repeat for more: `-v` enables + /// `info`, `-vv` enables `debug`, `-vvv` enables `trace`. + #[arg(long, short, global = true, action = clap::ArgAction::Count)] + verbose: u8, + /// Suppress non-error stderr output (overrides `--verbose`). + #[arg(long, short, global = true)] + quiet: bool, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Debug, Subcommand)] +enum Cmd { + /// Run migrations only (auto-backs-up by default). + Migrate(MigrateArgs), + /// Online backup to a timestamped `.db` file (or explicit path). + Backup(BackupArgs), + /// Replace --db with the contents of a backup. + Restore(RestoreArgs), + /// Apply retention to a backup directory. + Prune(PruneArgs), + /// Dump per-table row counts. + Inspect(InspectArgs), +} + +#[derive(Debug, Args)] +struct MigrateArgs { + #[arg(long)] + no_auto_backup: bool, +} + +#[derive(Debug, Args)] +struct BackupArgs { + /// Output directory OR full file path. + #[arg(long, value_name = "PATH")] + out: PathBuf, +} + +#[derive(Debug, Args)] +struct RestoreArgs { + #[arg(long, value_name = "PATH")] + from: PathBuf, + #[arg(long)] + yes: bool, + /// Skip the pre-restore auto-backup of the live destination DB. + /// Without this, the persister writes `pre-restore-.db` to + /// `--auto-backup-dir` before clobbering the destination. + #[arg(long)] + no_auto_backup: bool, +} + +#[derive(Debug, Args)] +struct PruneArgs { + #[arg(long = "in", value_name = "DIR")] + in_dir: PathBuf, + #[arg(long)] + keep_last: Option, + #[arg(long, value_parser = parse_duration)] + max_age: Option, +} + +#[derive(Debug, Args)] +struct InspectArgs { + #[arg(long)] + wallet_id: Option, + #[arg(long, default_value = "text")] + format: InspectFormat, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum InspectFormat { + Text, + Tsv, + Json, +} + +fn parse_duration(s: &str) -> Result { + humantime::parse_duration(s).map_err(|e| format!("invalid duration `{s}`: {e}")) +} + +fn parse_wallet_id(s: &str) -> Result<[u8; 32], String> { + if s.len() != 64 { + return Err(format!( + "wallet id must be 64 hex characters, got {} (`{}`)", + s.len(), + s + )); + } + let bytes = hex::decode(s).map_err(|e| format!("wallet id is not valid hex: {e}"))?; + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + init_tracing(cli.verbose, cli.quiet); + match run(cli) { + Ok(code) => code, + Err(err) => { + eprintln!("error: {}", err.message); + err.code + } + } +} + +fn init_tracing(verbose: u8, quiet: bool) { + use tracing_subscriber::EnvFilter; + let level = if quiet { + "error" + } else { + match verbose { + 0 => "warn", + 1 => "info", + 2 => "debug", + _ => "trace", + } + }; + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(format!("platform_wallet_storage={level}"))); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .try_init(); +} + +struct CliError { + message: String, + code: ExitCode, +} + +impl CliError { + fn runtime(msg: impl Into) -> Self { + Self { + message: msg.into(), + code: ExitCode::from(1), + } + } + fn validation(msg: impl Into) -> Self { + Self { + message: msg.into(), + code: ExitCode::from(3), + } + } +} + +fn run(cli: Cli) -> Result { + let db = cli + .db + .ok_or_else(|| CliError::runtime("--db is required"))?; + let auto_backup_dir = match cli.auto_backup_dir { + None => None, + Some(s) if s.is_empty() => { + // CODE-030: empty-string sentinel for "disable auto-backup" + // is deprecated in favour of the subcommand flag + // `--no-auto-backup`. Keep parsing it for one release so + // existing operators don't break overnight, but emit a + // loud deprecation warning on stderr. + eprintln!( + "warning: `--auto-backup-dir \"\"` to disable auto-backup is deprecated; \ + pass `--no-auto-backup` to the subcommand instead" + ); + Some(None) + } + Some(s) => Some(Some(PathBuf::from(s))), + }; + + // For `prune`, we don't open a persister — pure filesystem op. + if let Cmd::Prune(args) = &cli.cmd { + return run_prune(args); + } + + // `restore` is an associated function; no persister needed beforehand. + if let Cmd::Restore(args) = &cli.cmd { + return run_restore(&db, args, auto_backup_dir.as_ref()); + } + + // For `migrate --no-auto-backup`, we must keep `auto_backup_dir = + // None` so the open-time pre-migration backup is skipped. For + // every other subcommand we leave the user-configured dir (or the + // default) in place — the library's safe-by-default semantics + // still apply. + let mut config = SqlitePersisterConfig::new(&db); + if let Some(dir_opt) = auto_backup_dir.clone() { + config = config.with_auto_backup_dir(dir_opt); + } + if let Cmd::Migrate(m) = &cli.cmd { + if matches!(&auto_backup_dir, Some(None)) && !m.no_auto_backup { + return Err(CliError { + message: "auto-backup directory not configured; pass --no-auto-backup to proceed" + .to_string(), + code: ExitCode::from(1), + }); + } + if m.no_auto_backup { + config = config.with_auto_backup_dir(None); + eprintln!("warning: auto-backup skipped (--no-auto-backup)"); + } + } + + // Migrate (idempotent): open performs it. We capture the prior + // schema version so we can print "applied: N". A transient read + // failure must surface — silently reading 0 would print a wrong + // `applied:` count. + if let Cmd::Migrate(_) = &cli.cmd { + let pre_version = peek_schema_version(&db).map_err(|e| CliError::runtime(e.to_string()))?; + let _persister = SqlitePersister::open(config.clone()).map_err(map_open_err_for_cli)?; + let post_version = + peek_schema_version(&db).map_err(|e| CliError::runtime(e.to_string()))?; + let applied = post_version + .unwrap_or(0) + .saturating_sub(pre_version.unwrap_or(0)) as usize; + println!("applied: {applied}"); + return Ok(ExitCode::SUCCESS); + } + + match cli.cmd { + Cmd::Migrate(_) | Cmd::Prune(_) | Cmd::Restore(_) => unreachable!(), + Cmd::Backup(args) => { + let persister = SqlitePersister::open(config).map_err(map_open_err_for_cli)?; + run_backup(&persister, args) + } + Cmd::Inspect(args) => { + let persister = SqlitePersister::open(config).map_err(map_open_err_for_cli)?; + run_inspect(&persister, args) + } + } +} + +fn map_open_err_for_cli(err: WalletStorageError) -> CliError { + match err { + WalletStorageError::AutoBackupDisabled { + operation: AutoBackupOperation::OpenMigration, + } => CliError { + message: "auto-backup directory not configured; pass --no-auto-backup to proceed" + .to_string(), + code: ExitCode::from(1), + }, + WalletStorageError::Io(e) => CliError::runtime(format!("failed to open database: {e}")), + other => CliError::runtime(other.to_string()), + } +} + +/// Read the highest applied migration version. `Ok(None)` means the +/// DB has no `refinery_schema_history` row yet (fresh DB); a real open +/// or query failure is propagated as `Err` so callers don't mistake a +/// transient failure for "version 0". +fn peek_schema_version(db: &Path) -> Result, rusqlite::Error> { + use rusqlite::OptionalExtension; + let conn = rusqlite::Connection::open(db)?; + // Pre-migration the history table may not exist yet — that is a + // legitimate "no version" answer, not a failure. + let has_history = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !has_history { + return Ok(None); + } + let v = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get::<_, Option>(0), + ) + .optional()? + .flatten(); + Ok(v) +} + +fn run_backup(persister: &SqlitePersister, args: BackupArgs) -> Result { + // `backup_to` is the single authority on refuse-to-overwrite — it + // returns `BackupDestinationExists` for a pre-existing file path. + let path = persister.backup_to(&args.out).map_err(|e| match e { + WalletStorageError::BackupDestinationExists { path } => CliError::runtime(format!( + "backup destination exists and refuses to overwrite: {}", + path.display() + )), + other => CliError::runtime(other.to_string()), + })?; + println!("{}", path.display()); + Ok(ExitCode::SUCCESS) +} + +fn run_restore( + db: &Path, + args: &RestoreArgs, + auto_backup_dir: Option<&Option>, +) -> Result { + if !args.yes { + return Err(CliError { + message: "refusing to restore without --yes".into(), + code: ExitCode::from(2), + }); + } + let result = if args.no_auto_backup { + eprintln!("warning: auto-backup skipped (--no-auto-backup)"); + SqlitePersister::restore_from_skip_backup(db, &args.from) + } else { + // CLI default mirrors the persister config default + // (`/backups/auto/`). The CLI doesn't open a + // persister here, so we compute the default inline. + let resolved_dir: Option = match auto_backup_dir { + None => Some(default_auto_backup_dir(db)), + Some(opt) => opt.clone(), + }; + SqlitePersister::restore_from(db, &args.from, resolved_dir.as_deref()) + }; + match result { + Ok(()) => Ok(ExitCode::SUCCESS), + Err(WalletStorageError::IntegrityCheckFailed { report }) => Err(CliError::validation( + format!("source backup failed integrity check: {report}"), + )), + Err(WalletStorageError::SchemaHistoryMissing) => Err(CliError::validation( + "source backup failed integrity check: schema history missing".to_string(), + )), + Err(WalletStorageError::AutoBackupDisabled { .. }) => Err(CliError::runtime( + "auto-backup directory not configured; pass --no-auto-backup to proceed", + )), + Err(other) => Err(CliError::runtime(other.to_string())), + } +} + +fn run_prune(args: &PruneArgs) -> Result { + if args.keep_last.is_none() && args.max_age.is_none() { + return Err(CliError { + message: "at least one of --keep-last or --max-age is required".into(), + code: ExitCode::from(2), + }); + } + let policy = RetentionPolicy { + keep_last_n: args.keep_last, + max_age: args.max_age, + }; + let report = platform_wallet_storage::sqlite::backup::prune(&args.in_dir, policy) + .map_err(|e| CliError::runtime(e.to_string()))?; + for p in &report.removed { + println!("{}", p.display()); + } + for (p, e) in &report.failed_removals { + eprintln!("warning: failed to remove {}: {e}", p.display()); + } + // ATOM-011: non-zero exit when any per-file removal failed so + // scripts can detect the partial-success case. + if report.failed_removals.is_empty() { + Ok(ExitCode::SUCCESS) + } else { + Ok(ExitCode::from(1)) + } +} + +fn run_inspect(persister: &SqlitePersister, args: InspectArgs) -> Result { + let wallet_id = match args.wallet_id.as_deref() { + None => None, + Some(s) => Some(parse_wallet_id(s).map_err(|m| CliError { + message: m, + code: ExitCode::from(2), + })?), + }; + let counts = persister + .inspect_counts(wallet_id.as_ref()) + .map_err(|e| CliError::runtime(e.to_string()))?; + match args.format { + InspectFormat::Text | InspectFormat::Tsv => { + for (table, n) in counts { + println!("{table}\t{n}"); + } + } + InspectFormat::Json => { + let entries: Vec = counts + .into_iter() + .map(|(table, n)| match &wallet_id { + None => serde_json::json!({ "table": table, "count": n }), + Some(id) => serde_json::json!({ + "table": table, + "count": n, + "wallet_id": hex::encode(id), + }), + }) + .collect(); + println!( + "{}", + serde_json::to_string(&entries).map_err(|e| CliError::runtime(e.to_string()))? + ); + } + } + Ok(ExitCode::SUCCESS) +} diff --git a/packages/rs-platform-wallet-storage/src/lib.rs b/packages/rs-platform-wallet-storage/src/lib.rs new file mode 100644 index 00000000000..183a4519916 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/lib.rs @@ -0,0 +1,57 @@ +//! Storage backends for the `platform-wallet` crate. +//! +//! Today this crate ships the SQLite-backed +//! [`sqlite::SqlitePersister`] implementation of +//! [`PlatformWalletPersistence`](platform_wallet::changeset::PlatformWalletPersistence). +//! The crate is structured so a future `secrets` submodule — a +//! `SecretStore` for mnemonic / private-key material, sketched in +//! [`SECRETS.md`](../SECRETS.md) — can ship alongside it without a +//! crate split. +//! +//! ## Canonical type paths +//! +//! Both work; pick whichever reads better in your call site: +//! +//! ```rust,ignore +//! use platform_wallet_storage::SqlitePersister; // root re-export +//! use platform_wallet_storage::sqlite::SqlitePersister; // submodule re-export +//! use platform_wallet_storage::sqlite::persister::SqlitePersister; // deep path +//! ``` + +#![deny(rust_2018_idioms)] +#![deny(unsafe_code)] + +#[cfg(feature = "sqlite")] +pub mod sqlite; +// pub mod secrets; // reserved — future SecretStore submodule. + +// Convenience re-exports kept under the crate root so embedders don't +// have to spell out the `::sqlite::` middle segment for the common +// names. Adding to or trimming from this list does NOT count as a +// breaking change of the submodule API. +#[cfg(feature = "sqlite")] +#[allow(deprecated)] +pub use sqlite::{ + default_auto_backup_dir, AutoBackupOperation, FlushMode, JournalMode, PruneReport, + RetentionPolicy, SqlitePersister, SqlitePersisterConfig, SqlitePersisterError, Synchronous, + WalletStorageError, +}; + +// Compile-time assertions — `Send + Sync`, `PlatformWalletPersistence` +// object-safety, and the no-boxed-trait-object error policy. +// Lint-gated to the SQLite feature because they reference its types. +#[cfg(feature = "sqlite")] +#[allow(dead_code)] +const fn _send_sync_check() {} +#[cfg(feature = "sqlite")] +const _: () = { + _send_sync_check::(); + _send_sync_check::(); +}; + +#[cfg(feature = "sqlite")] +#[allow(dead_code)] +fn _object_safety_check(persister: SqlitePersister) { + let _: std::sync::Arc = + std::sync::Arc::new(persister); +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs new file mode 100644 index 00000000000..550ba141583 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs @@ -0,0 +1,554 @@ +//! Online backup, restore, and retention helpers. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use rusqlite::backup::Backup; +use rusqlite::Connection; + +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::persister::{PruneReport, RetentionPolicy}; +use crate::sqlite::util::permissions::apply_secure_permissions; + +/// CODE-014: fsync the parent directory of `path` on Unix so the +/// rename entry that materialised `path` is durable across power loss. +/// `persist` only fsyncs the file inode; on most Unix filesystems the +/// dentry update is journalled separately and can be lost on crash +/// without this step. No-op on non-Unix platforms. +#[cfg(unix)] +fn fsync_parent_dir(path: &Path) -> Result<(), WalletStorageError> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + let dir = std::fs::File::open(parent)?; + dir.sync_all()?; + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn fsync_parent_dir(_path: &Path) -> Result<(), WalletStorageError> { + Ok(()) +} + +/// Normalize an `open_conn` failure on a candidate source/staged file +/// to the typed [`WalletStorageError::SourceOpenFailed`]. A raw rusqlite +/// open error keeps its `#[source]`; any other variant (e.g. a future +/// FK assertion on a RW open) passes through unchanged. +fn map_source_open_err(err: WalletStorageError) -> WalletStorageError { + match err { + WalletStorageError::Sqlite(source) => WalletStorageError::SourceOpenFailed { source }, + other => other, + } +} + +/// Distinguishes auto-backup filenames. +#[derive(Debug, Clone, Copy)] +pub enum BackupKind { + PreMigration { from: i32, to: i32 }, + PreDelete { wallet_id: WalletId }, + PreRestore, +} + +/// Filename for `backup_to(directory)`. +pub fn manual_backup_filename() -> String { + format!("wallet-{}.db", utc_timestamp()) +} + +/// Filename for an auto-backup. +pub fn auto_backup_filename(kind: BackupKind) -> String { + let ts = utc_timestamp(); + match kind { + BackupKind::PreMigration { from, to } => format!("pre-migration-{from}-to-{to}-{ts}.db"), + BackupKind::PreDelete { wallet_id } => { + format!("pre-delete-{}-{ts}.db", hex::encode(wallet_id)) + } + BackupKind::PreRestore => format!("pre-restore-{ts}.db"), + } +} + +/// Take an online backup of `src` to `dest`. Uses the +/// `rusqlite::backup::Backup::run_to_completion` page-stepping API +/// so writers aren't blocked. +/// +/// # Atomicity +/// +/// The page-stepping copy runs against a `NamedTempFile` staged in +/// `dest`'s parent directory. The temp is `persist_noclobber`-ed over +/// `dest` only on success — any failure (open, chmod, backup-stream) +/// drops the temp without ever materialising a partial `.db` file at +/// the caller's path. A pre-existing `dest` is rejected atomically by +/// `persist_noclobber` (no TOCTOU window). On Unix, the parent +/// directory is `fsync`-ed after the rename so the dentry update +/// survives power loss; on non-Unix this fsync step is a no-op. +pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { + if let Some(parent) = dest.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + // CODE-009: pre-existing-destination rejection happens at the + // `persist_noclobber` site below — that's atomic against the rename + // (no TOCTOU window between `dest.exists()` and persist). The + // CLI's `backup_to(file_path)` still gets the typed + // `BackupDestinationExists` error; auto-backup callers can't trip + // it because the filename carries a unique timestamp suffix. + + // Stage the backup into an unguessable temp file in the same + // directory. Same-FS guarantee makes `persist` an atomic rename. + let parent = dest.parent().unwrap_or(Path::new(".")); + let tmp = tempfile::NamedTempFile::new_in(parent)?; + // SEC-011: tighten the temp's mode to 0o600 BEFORE persist so the + // destination inherits owner-only permissions via the atomic + // rename. Running chmod after persist would leave a brief + // umask-default window where the destination is observable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tmp.as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + + // Page-stepping copy against the temp. The dest Connection has to + // own its own file handle; rusqlite opens it from a path. + let mut backup_conn = + crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadWrite)?; + { + let backup = Backup::new(src, &mut backup_conn)?; + // 100 pages × 4 KiB = 400 KiB per step on default SQLite page size. + backup.run_to_completion(100, Duration::from_millis(5), None)?; + } + // Close the backup Connection before persisting so SQLite flushes + // its own WAL/SHM siblings against the temp path — those go away + // with the rename since `persist` atomically renames the temp file. + drop(backup_conn); + + // CODE-009: `persist_noclobber` is the atomic check-and-rename — + // SQLite-free, no TOCTOU window between an `exists()` probe and the + // rename. `AlreadyExists` maps to the typed + // `BackupDestinationExists` for the CLI's overwrite-refusal contract. + tmp.persist_noclobber(dest).map_err(|e| { + if e.error.kind() == std::io::ErrorKind::AlreadyExists { + WalletStorageError::BackupDestinationExists { + path: dest.to_path_buf(), + } + } else { + WalletStorageError::Io(e.error) + } + })?; + // CODE-014: fsync the parent directory so the atomic rename's + // dentry update is durable across power loss. On non-Unix this is + // a no-op. + fsync_parent_dir(dest)?; + // SEC-011: re-tighten in case a non-Unix build (or a future + // platform-specific tweak) needs to refresh sibling perms after + // SQLite materialised them. No-op on Unix where the temp already + // landed at 0o600. + apply_secure_permissions(dest)?; + Ok(()) +} + +/// Restore a `.db` backup over `dest_db_path`. Associated function; +/// caller must guarantee the destination is not held open by this +/// process. The caller (the persister's `restore_from_inner`) handles +/// the pre-restore auto-backup gate. +/// +/// # Atomicity +/// +/// The restore is staged in two phases bounded by a SQLite-native +/// `BEGIN EXCLUSIVE` transaction on `dest_db_path` (kept across the +/// entire restore body): +/// +/// 1. Open the source read-only; run `PRAGMA integrity_check` + +/// schema-history + max-version sniffs. Any failure here aborts +/// before the live destination is touched. +/// 2. Open a short-lived writer connection on the destination and +/// `BEGIN EXCLUSIVE`. This blocks every other SQLite peer +/// (other `SqlitePersister` handles in this or sibling processes, +/// bare `rusqlite::Connection`s, the CLI) from writing the file +/// until restore completes. Peers waiting for the lock back off +/// via SQLite's own busy_timeout. The lock conn is DROPPED right +/// before `persist` so SQLite releases its file handle on the old +/// inode before the atomic rename takes its place. +/// 3. Stream the source into a `NamedTempFile` in `dest_db_path`'s +/// parent directory; re-run integrity + schema gates against the +/// STAGED bytes (catches a torn `io::copy`); unlink the existing +/// `-wal` / `-shm` siblings; chmod the temp to 0o600; then +/// `persist` over `dest_db_path` as an atomic rename. +/// +/// Either both the main DB and its WAL/SHM siblings are replaced, or +/// — on any pre-persist failure — none of them are touched. The +/// SQLite-native lock prevents a racing peer from committing rows +/// between the staged validation and the rename, which the prior +/// flock-based approach could not do (flock doesn't see SQLite peers). +/// +/// On Unix, the parent directory is `fsync`-ed after the rename so the +/// dentry update is durable across power loss; on non-Unix this is a +/// no-op. +pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), WalletStorageError> { + // 1. Confirm the source is openable, then run cheap pre-staging + // integrity + schema-history + max-version sniffs against the + // source itself so an obviously-incompatible input fails before + // we stream the whole file into the destination's partition. + // The authoritative schema-history / version gate still re-runs + // on the STAGED copy (step 4) — that's the TOCTOU-safe check + // bound to the exact bytes about to be persisted. + let src = crate::sqlite::conn::open_conn(src_backup, crate::sqlite::conn::Access::ReadOnly) + .map_err(map_source_open_err)?; + run_integrity_check(&src, |report| WalletStorageError::IntegrityCheckFailed { + report, + })?; + if !crate::sqlite::migrations::has_schema_history(&src)? { + return Err(WalletStorageError::SchemaHistoryMissing); + } + crate::sqlite::migrations::assert_schema_version_supported(&src)?; + drop(src); + + // 2. SQLite-native exclusion. `BEGIN EXCLUSIVE` against a short- + // lived writer connection on the destination blocks every other + // SQLite peer (rusqlite Connection, sibling `SqlitePersister`) + // until the tx is committed/rolled-back or the conn drops. The + // prior flock approach was a false promise: advisory locks + // don't interlock with SQLite's own locking, so a peer mid-write + // could race the swap. The lock conn is dropped (`take()` + end + // of scope) BEFORE `tmp.persist` so SQLite releases its file + // handle on the old inode before the atomic rename — otherwise + // we'd leave a dangling handle on the unlinked inode. + let mut dest_lock_conn: Option = if dest_db_path.exists() { + let conn = + crate::sqlite::conn::open_conn(dest_db_path, crate::sqlite::conn::Access::ReadWrite)?; + // Reuse a sensible busy_timeout so peers don't immediately + // surface BUSY without a backoff window. The destination DB + // may not have a persister attached yet (the persister is the + // CALLER), so this conn applies its own. + conn.busy_timeout(std::time::Duration::from_secs(5))?; + // Take EXCLUSIVE up-front by promoting an immediate tx. If a + // peer holds the DB, SQLite waits for busy_timeout then + // returns BUSY — we surface that as `RestoreDestinationLocked` + // so callers keep their existing branch. + match conn.execute_batch("BEGIN EXCLUSIVE") { + Ok(()) => Some(conn), + Err(rusqlite::Error::SqliteFailure(err, _)) + if matches!( + err.code, + rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked + ) => + { + return Err(WalletStorageError::RestoreDestinationLocked); + } + Err(other) => return Err(WalletStorageError::Sqlite(other)), + } + } else { + None + }; + + // 3. Stage the source into a NamedTempFile in the destination's + // parent dir (unguessable name, no symlink-plant TOCTOU). + let parent = dest_db_path.parent().unwrap_or(Path::new(".")); + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + let mut src_file = std::fs::File::open(src_backup)?; + std::io::copy(&mut src_file, tmp.as_file_mut())?; + tmp.as_file().sync_all()?; + + // 4. SEC-004: re-run integrity_check on the STAGED file before + // persisting. A torn `std::io::copy` or transient FS error + // that escaped `sync_all`'s notice would otherwise persist a + // corrupted database. If the recheck fails, the temp file + // drops naturally and the live destination stays untouched. + { + let staged = + crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadOnly) + .map_err(map_source_open_err)?; + run_integrity_check(&staged, |report| WalletStorageError::IntegrityCheckFailed { + report, + })?; + // Schema-history presence + max-version gate, bound to the + // staged bytes (not the first source handle) so a swap during + // the restore window can't slip a forward-version DB through. + if !crate::sqlite::migrations::has_schema_history(&staged)? { + return Err(WalletStorageError::SchemaHistoryMissing); + } + crate::sqlite::migrations::assert_schema_version_supported(&staged)?; + } + + // 5. Atomicity gate: every staged-file validation has now passed, + // so it's safe to clear WAL/SHM siblings the replaced DB might + // have left behind. Doing this BEFORE persist ensures that + // either both the main DB and its siblings get replaced/cleared, + // or — if any earlier check failed — none of them are touched. + // + // CODE-011: build sibling paths via `OsString::push` so non-UTF-8 + // bytes round-trip intact; `remove_file` runs unconditionally and + // `ErrorKind::NotFound` is a silent no-op (closes the `exists()` + // TOCTOU gate). + if let Some(file_name) = dest_db_path.file_name() { + for ext in ["-wal", "-shm"] { + let mut sibling_name = file_name.to_os_string(); + sibling_name.push(ext); + let sibling = dest_db_path.with_file_name(sibling_name); + match std::fs::remove_file(&sibling) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(WalletStorageError::Io(e)), + } + } + } + + // 6. ATOM-010 (A-5): chmod 600 on the temp BEFORE persist so the + // destination inherits owner-only mode via the atomic rename. + // Pre-A-5 the chmod ran post-persist — a rare chmod failure + // returned Err while leaving the new DB live at the destination + // (caller thought restore rolled back, reality was mixed). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tmp.as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + + // 7. Release the SQLite-native EXCLUSIVE lock BEFORE the rename. + // Dropping `dest_lock_conn` causes SQLite to close its file + // handle on the old inode; if we kept it alive across `persist` + // the handle would point at the unlinked old inode while the + // new DB took its place — peers reopening would race the rename + // and (on some filesystems) the rename itself could fail. + if let Some(conn) = dest_lock_conn.take() { + // Best-effort rollback of the empty EXCLUSIVE tx; an error here + // means SQLite is already in trouble and `drop(conn)` covers + // the rest. Silent because the conn is about to drop anyway. + let _ = conn.execute_batch("ROLLBACK"); + drop(conn); + } + + // 8. Persist atomically over the destination. + tmp.persist(dest_db_path) + .map_err(|e| WalletStorageError::Io(e.error))?; + + // 9. CODE-014: fsync the destination's parent directory so the + // atomic rename's dentry update is durable across power loss + // (no-op on non-Unix). + fsync_parent_dir(dest_db_path)?; + + // 10. Re-tighten siblings (SQLite may materialise -wal/-shm on next + // open; this is idempotent at restore-completion time). + apply_secure_permissions(dest_db_path)?; + Ok(()) +} + +/// Run `PRAGMA integrity_check` and return `Ok(())` when SQLite reports +/// the single row `"ok"`. Any other result becomes a typed +/// `IntegrityCheckFailed` via the caller-supplied builder; an +/// underlying rusqlite error surfaces as `IntegrityCheckRunFailed`. +/// +/// CODE-016: SQLite returns one row per detected problem (capped at +/// `PRAGMA integrity_check(N)`; default 100). All rows are collected +/// and joined with `\n` so the typed report carries every diagnostic +/// instead of just the first line. +/// +/// `pub(crate)` so the persister's open-time A-8 probe shares the +/// same helper rather than reimplementing the report-rendering rule. +pub(crate) fn run_integrity_check( + conn: &Connection, + on_failure: F, +) -> Result<(), WalletStorageError> +where + F: FnOnce(String) -> WalletStorageError, +{ + let mut stmt = conn + .prepare("PRAGMA integrity_check") + .map_err(|source| WalletStorageError::IntegrityCheckRunFailed { source })?; + let mut rows: Vec = Vec::new(); + let mut trailing_err: Option = None; + let iter = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|source| WalletStorageError::IntegrityCheckRunFailed { source })?; + for item in iter { + match item { + Ok(s) => rows.push(s), + Err(e) => { + // Severe corruption can cause SQLite to surface a + // `DatabaseCorrupt` SqliteFailure partway through the + // integrity_check stream. Treat it as end-of-stream + // when we already have diagnostics (the rows we have + // are still valid); if we have NOTHING, surface the + // typed `IntegrityCheckRunFailed`. + trailing_err = Some(e); + break; + } + } + } + if rows.is_empty() { + if let Some(source) = trailing_err { + return Err(WalletStorageError::IntegrityCheckRunFailed { source }); + } + // Empty result with no error is unexpected but not "ok". + return Err(on_failure(String::new())); + } + if rows.len() == 1 && rows[0] == "ok" && trailing_err.is_none() { + Ok(()) + } else { + let mut report = rows.join("\n"); + if let Some(e) = trailing_err { + // Preserve the cut-off marker so operators see the stream + // was truncated, not just under-reported. + report.push_str(&format!("\n[integrity_check stream aborted: {e}]")); + } + Err(on_failure(report)) + } +} + +/// Apply retention to a directory. Files that match the recognised +/// backup-name prefixes are eligible; others are ignored. +/// +/// # Partial failures +/// +/// ATOM-011 / A-6: per-file `remove_file` failures are collected into +/// `PruneReport::failed_removals` rather than aborting the loop. The +/// happy path still removes every eligible file. Only catastrophic +/// errors (`read_dir` itself fails, an `entry?` returns Err) surface +/// as `Err(_)` — those affect every subsequent iteration too, so +/// continuing would just compound the failure. +pub fn prune(dir: &Path, policy: RetentionPolicy) -> Result { + let entries = std::fs::read_dir(dir)?; + let mut files: Vec<(SystemTime, PathBuf)> = Vec::new(); + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !is_backup_file(&path) { + continue; + } + let ts = backup_timestamp(&path).unwrap_or_else(|| { + entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) + }); + files.push((ts, path)); + } + // Newest first. + files.sort_by(|a, b| b.0.cmp(&a.0)); + let now = SystemTime::now(); + let mut removed = Vec::new(); + let mut failed_removals: Vec<(PathBuf, std::io::Error)> = Vec::new(); + let mut kept = 0; + for (idx, (ts, path)) in files.into_iter().enumerate() { + let pass_count = match policy.keep_last_n { + Some(n) => idx < n, + None => true, + }; + let pass_age = match policy.max_age { + Some(max) => now.duration_since(ts).map(|d| d <= max).unwrap_or(true), + None => true, + }; + if pass_count && pass_age { + kept += 1; + } else { + match std::fs::remove_file(&path) { + Ok(()) => removed.push(path), + Err(e) => { + // CODE-019: a failed `remove_file` leaves the file + // on disk, so it MUST be counted in `kept`. The + // invariant `kept + removed.len() == total` then + // holds and `failed_removals` is a subset of + // `kept`. + failed_removals.push((path, e)); + kept += 1; + } + } + } + } + // Sort `removed` oldest-first for deterministic output. + removed.sort(); + failed_removals.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(PruneReport { + removed, + kept, + failed_removals, + }) +} + +fn is_backup_file(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return false; + }; + (name.starts_with("wallet-") + || name.starts_with("pre-migration-") + || name.starts_with("pre-delete-") + || name.starts_with("pre-restore-")) + && name.ends_with(".db") +} + +fn backup_timestamp(path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + // Find the last `YYYYMMDDTHHMMSSZ` token before `.db`. + let stem = name.strip_suffix(".db")?; + let token = stem.rsplit('-').next()?; + parse_compact_timestamp(token) +} + +fn parse_compact_timestamp(s: &str) -> Option { + // Expect 16 chars: `YYYYMMDDTHHMMSSZ`. + if s.len() != 16 { + return None; + } + let year: i32 = s.get(0..4)?.parse().ok()?; + let month: u32 = s.get(4..6)?.parse().ok()?; + let day: u32 = s.get(6..8)?.parse().ok()?; + if s.as_bytes().get(8) != Some(&b'T') { + return None; + } + let hour: u32 = s.get(9..11)?.parse().ok()?; + let minute: u32 = s.get(11..13)?.parse().ok()?; + let second: u32 = s.get(13..15)?.parse().ok()?; + if s.as_bytes().get(15) != Some(&b'Z') { + return None; + } + use chrono::{TimeZone, Utc}; + let dt = Utc + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single()?; + Some(SystemTime::UNIX_EPOCH + Duration::from_secs(dt.timestamp().max(0) as u64)) +} + +fn utc_timestamp() -> String { + chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manual_backup_filename_matches_regex() { + let n = manual_backup_filename(); + assert!(n.starts_with("wallet-")); + assert!(n.ends_with(".db")); + assert_eq!(n.len(), "wallet-YYYYMMDDTHHMMSSZ.db".len()); + } + + #[test] + fn timestamp_roundtrip() { + let ts = parse_compact_timestamp("20260101T000000Z").unwrap(); + let secs = ts.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + // 2026-01-01 00:00:00 UTC = 1767225600 + assert_eq!(secs, 1767225600); + } + + #[test] + fn is_backup_file_recognises_prefixes() { + assert!(is_backup_file(Path::new("/tmp/wallet-20260101T000000Z.db"))); + assert!(is_backup_file(Path::new( + "/tmp/pre-migration-1-to-2-20260101T000000Z.db" + ))); + assert!(is_backup_file(Path::new( + "/tmp/pre-delete-abcd-20260101T000000Z.db" + ))); + assert!(is_backup_file(Path::new( + "/tmp/pre-restore-20260101T000000Z.db" + ))); + assert!(!is_backup_file(Path::new("/tmp/notes.txt"))); + assert!(!is_backup_file(Path::new("/tmp/wallet.db"))); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs b/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs new file mode 100644 index 00000000000..311a2f17411 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs @@ -0,0 +1,167 @@ +//! Per-wallet in-memory buffer. +//! +//! `store` merges the incoming changeset into a per-wallet accumulator +//! using each sub-changeset's `Merge` impl. `flush` drains one wallet's +//! accumulator and returns the owned changeset for the schema dispatcher +//! to write under one SQLite transaction. The buffer never owns the +//! database connection. + +use std::collections::HashMap; +use std::sync::Mutex; + +use platform_wallet::changeset::{Merge, PlatformWalletChangeSet}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; + +#[derive(Default)] +pub struct Buffer { + inner: Mutex>, +} + +impl Buffer { + pub fn new() -> Self { + Self::default() + } + + /// Merge a changeset into the buffer for `wallet_id`. + pub fn store( + &self, + wallet_id: WalletId, + cs: PlatformWalletChangeSet, + ) -> Result<(), WalletStorageError> { + if cs.is_empty() { + return Ok(()); + } + let mut guard = self + .inner + .lock() + .map_err(|_| WalletStorageError::LockPoisoned)?; + guard.entry(wallet_id).or_default().merge(cs); + Ok(()) + } + + /// Move the buffered changeset out for `wallet_id`. Returns + /// `None` when nothing is staged. Callers MUST either commit it + /// (success path) or hand it back via [`Self::restore`] on + /// transient failure — dropping it on error == data loss. + pub fn take_for_flush( + &self, + wallet_id: &WalletId, + ) -> Result, WalletStorageError> { + let mut guard = self + .inner + .lock() + .map_err(|_| WalletStorageError::LockPoisoned)?; + Ok(guard.remove(wallet_id).filter(|cs| !cs.is_empty())) + } + + /// Re-merge a previously-taken changeset back into the buffer + /// after a transient flush failure. Uses each sub-changeset's + /// `Merge` impl so any `store(...)` that arrived between the + /// `take_for_flush` and the failure wins on overlapping fields + /// (LWW). No clone: the caller hands ownership back. + pub fn restore( + &self, + wallet_id: WalletId, + cs: PlatformWalletChangeSet, + ) -> Result<(), WalletStorageError> { + if cs.is_empty() { + return Ok(()); + } + let mut guard = self + .inner + .lock() + .map_err(|_| WalletStorageError::LockPoisoned)?; + // Merge `cs` (older snapshot) FIRST, then re-apply anything + // that arrived later — done by swapping current with `cs` and + // merging the (originally newer) buffered value on top. + let entry = guard.entry(wallet_id).or_default(); + let newer = std::mem::take(entry); + *entry = cs; + entry.merge(newer); + Ok(()) + } + + /// Deprecated alias for [`Self::take_for_flush`]. New call sites + /// MUST use the renamed pair so the take/restore lifecycle is + /// explicit. + #[deprecated( + since = "3.1.0-dev.1", + note = "use take_for_flush + restore for retry-safe semantics; remove in 3.2.0" + )] + pub fn drain( + &self, + wallet_id: &WalletId, + ) -> Result, WalletStorageError> { + self.take_for_flush(wallet_id) + } + + /// Every wallet currently holding buffered data, sorted by id for + /// deterministic flush ordering. + pub fn dirty_wallets(&self) -> Result, WalletStorageError> { + let guard = self + .inner + .lock() + .map_err(|_| WalletStorageError::LockPoisoned)?; + let mut ids: Vec = guard.keys().copied().collect(); + ids.sort(); + Ok(ids) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_wallet::changeset::CoreChangeSet; + + fn cs_height(synced: u32, last_processed: u32) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(synced), + last_processed_height: Some(last_processed), + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn take_then_restore_with_intervening_store_merges_lww() { + let buf = Buffer::new(); + let w = [0xAAu8; 32]; + // Stage A (older), take it out. + buf.store(w, cs_height(10, 10)).unwrap(); + let taken = buf + .take_for_flush(&w) + .unwrap() + .expect("staged value present"); + // B arrives during the imagined flush window. + buf.store(w, cs_height(20, 5)).unwrap(); + // Restore the taken (older) snapshot — newer must win on the + // monotonic-max merge of `synced_height` / `last_processed_height`. + buf.restore(w, taken).unwrap(); + let merged = buf + .take_for_flush(&w) + .unwrap() + .expect("merged value present"); + let core = merged.core.expect("core present"); + assert_eq!(core.synced_height, Some(20)); + assert_eq!(core.last_processed_height, Some(10)); + } + + #[test] + fn restore_into_empty_slot_inserts() { + let buf = Buffer::new(); + let w = [0xBBu8; 32]; + // Buffer has nothing for `w`; restore must seed the slot. + buf.restore(w, cs_height(7, 7)).unwrap(); + let got = buf + .take_for_flush(&w) + .unwrap() + .expect("restored value present"); + let core = got.core.expect("core present"); + assert_eq!(core.synced_height, Some(7)); + assert_eq!(core.last_processed_height, Some(7)); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/config.rs b/packages/rs-platform-wallet-storage/src/sqlite/config.rs new file mode 100644 index 00000000000..1beb7c2c021 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/config.rs @@ -0,0 +1,141 @@ +//! Configuration for [`SqlitePersister`](crate::SqlitePersister). + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// When `store()` makes data durable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FlushMode { + /// `store()` only buffers. Caller must call `flush()` (or + /// `commit_writes()`) to make changes durable. + Manual, + /// `store()` flushes inline at the end of the call. Safest default. + #[default] + Immediate, +} + +/// SQLite journal mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum JournalMode { + #[default] + Wal, + Delete, + Memory, + Off, + Truncate, + Persist, +} + +impl JournalMode { + pub(crate) fn pragma_value(self) -> &'static str { + match self { + JournalMode::Wal => "WAL", + JournalMode::Delete => "DELETE", + JournalMode::Memory => "MEMORY", + JournalMode::Off => "OFF", + JournalMode::Truncate => "TRUNCATE", + JournalMode::Persist => "PERSIST", + } + } +} + +/// SQLite synchronous mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Synchronous { + Off, + #[default] + Normal, + Full, + Extra, +} + +impl Synchronous { + pub(crate) fn pragma_value(self) -> &'static str { + match self { + Synchronous::Off => "OFF", + Synchronous::Normal => "NORMAL", + Synchronous::Full => "FULL", + Synchronous::Extra => "EXTRA", + } + } +} + +/// Persister configuration. +/// +/// Defaults match the dash-evo-tool behaviour: `Immediate` flushes, +/// 5 s busy timeout, WAL journal, `NORMAL` synchronous, automatic +/// backups under `/backups/auto/`. +#[derive(Debug, Clone)] +pub struct SqlitePersisterConfig { + pub path: PathBuf, + pub flush_mode: FlushMode, + pub busy_timeout: Duration, + pub journal_mode: JournalMode, + pub synchronous: Synchronous, + /// Where automatic backups (pre-migration, pre-wallet-deletion) are + /// written. Set to `None` to disable automatic backups — library + /// API destructive operations then return + /// [`WalletStorageError::AutoBackupDisabled`](crate::WalletStorageError::AutoBackupDisabled). + pub auto_backup_dir: Option, +} + +impl SqlitePersisterConfig { + /// Build a config with sensible defaults for the given DB path. + pub fn new(path: impl Into) -> Self { + let path = path.into(); + let auto_backup_dir = default_auto_backup_dir(&path); + Self { + path, + flush_mode: FlushMode::default(), + busy_timeout: Duration::from_secs(5), + journal_mode: JournalMode::default(), + synchronous: Synchronous::default(), + auto_backup_dir: Some(auto_backup_dir), + } + } + + /// Override flush mode. + pub fn with_flush_mode(mut self, mode: FlushMode) -> Self { + self.flush_mode = mode; + self + } + + /// Override auto-backup dir. Pass `None` to opt out. + pub fn with_auto_backup_dir(mut self, dir: Option) -> Self { + self.auto_backup_dir = dir; + self + } +} + +/// `/backups/auto/` (or `./backups/auto/` if the DB path has no parent). +/// +/// Public so the CLI binary (a separate compilation unit) can share the +/// same resolution as the library's `SqlitePersisterConfig::new`. The +/// preferred narrower visibility would be `pub(super)`, but `pub use` +/// re-exports up to the crate root cannot expose a `pub(super)` item. +pub fn default_auto_backup_dir(db_path: &Path) -> PathBuf { + let parent = db_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + parent.join("backups").join("auto") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_spec() { + let cfg = SqlitePersisterConfig::new("/tmp/w.db"); + assert_eq!(cfg.flush_mode, FlushMode::Immediate); + assert_eq!(cfg.busy_timeout, Duration::from_secs(5)); + assert_eq!(cfg.journal_mode, JournalMode::Wal); + assert_eq!(cfg.synchronous, Synchronous::Normal); + assert_eq!( + cfg.auto_backup_dir.as_deref(), + Some(std::path::Path::new("/tmp/backups/auto")) + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs new file mode 100644 index 00000000000..64603bac9fa --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs @@ -0,0 +1,100 @@ +//! Single connection-open choke-point. +//! +//! `PRAGMA foreign_keys` is per-connection and resets to OFF on every +//! open — it is not persisted in the database file, and no compile-time +//! knob in `libsqlite3-sys`'s bundled build forces it on. Enforcement is +//! therefore a runtime discipline: every connection that mutates rows +//! must enable it, and we must *prove* it took, because the pragma +//! silently no-ops on a SQLite built without FK support. +//! +//! Every library connection-open site routes through [`open_conn`] so +//! there is exactly one place that owns flags + FK enforcement. The CLI +//! binary's read-only `peek_schema_version` probe opens directly — it +//! never mutates rows, so FK enforcement is moot, and `open_conn` is +//! `pub(crate)` (not reachable from the separate bin target). + +use rusqlite::{Connection, OpenFlags}; +use std::path::Path; + +use crate::sqlite::error::WalletStorageError; + +/// How the opened connection will be used. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Access { + /// Read-write writer connection. Enables `foreign_keys` and asserts + /// the read-back equals 1. + ReadWrite, + /// Read-only handle (backup source, restore probe, CLI peek). FK + /// enforcement is irrelevant — no mutations flow through it — so the + /// pragma + read-back are skipped. + ReadOnly, +} + +/// Open a SQLite connection through the crate's single choke-point. +/// +/// For [`Access::ReadWrite`], enables `PRAGMA foreign_keys = ON` and +/// reads it back, returning [`WalletStorageError::ForeignKeysNotEnforced`] +/// if the result is not `1`. For [`Access::ReadOnly`], opens with +/// `SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_URI` and performs no pragma. +pub(crate) fn open_conn(path: &Path, access: Access) -> Result { + let conn = match access { + Access::ReadWrite => Connection::open(path)?, + Access::ReadOnly => Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + )?, + }; + if access == Access::ReadWrite { + enforce_foreign_keys(&conn)?; + } + Ok(conn) +} + +/// Enable `foreign_keys` and assert via read-back. Separated so the +/// writer can call it after re-opening through other paths if needed. +pub(crate) fn enforce_foreign_keys(conn: &Connection) -> Result<(), WalletStorageError> { + conn.pragma_update(None, "foreign_keys", "ON")?; + let on: i64 = conn.pragma_query_value(None, "foreign_keys", |r| r.get(0))?; + if on != 1 { + return Err(WalletStorageError::ForeignKeysNotEnforced); + } + Ok(()) +} + +/// Flip `PRAGMA foreign_keys` on or off explicitly. Used by the +/// migration runner to disable FK enforcement around the V002 schema +/// rewrite (DROP TABLE on a parent fires ON DELETE CASCADE on its +/// children otherwise, wiping the rows the migration is trying to +/// preserve). The pragma cannot be flipped inside an open +/// transaction, so the caller must invoke this before BEGIN. +pub(crate) fn set_foreign_keys(conn: &Connection, on: bool) -> Result<(), WalletStorageError> { + conn.pragma_update(None, "foreign_keys", if on { "ON" } else { "OFF" })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A read-write open enables FK and the read-back confirms it — the + /// assertion path that guards against a silently no-op pragma. + #[test] + fn read_write_open_enforces_and_reads_back_foreign_keys() { + let conn = Connection::open_in_memory().expect("in-memory open"); + enforce_foreign_keys(&conn).expect("FK enforcement"); + let on: i64 = conn + .pragma_query_value(None, "foreign_keys", |r| r.get(0)) + .expect("read-back"); + assert_eq!(on, 1, "read-back must observe FK enforcement is on"); + } + + /// The hard-error variant the read-back returns when the pragma is a + /// no-op is wired and reachable. We can't build a FK-less SQLite in + /// the bundled build, so assert the typed error renders the intended + /// message rather than truncating the contract to "untestable". + #[test] + fn foreign_keys_not_enforced_variant_renders() { + let err = WalletStorageError::ForeignKeysNotEnforced; + assert!(format!("{err}").contains("foreign-key enforcement")); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs new file mode 100644 index 00000000000..18cbac29626 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -0,0 +1,480 @@ +//! Typed errors for `platform-wallet-storage`. +//! +//! Every variant carries the upstream error via `#[source]` (or +//! `#[from]` where the conversion is the only thing the trait does), +//! never via a stringified copy. Variants never store user-facing +//! prose — the `#[error("...")]` attribute provides the renderable +//! `Display` form; the typed fields carry diagnostics. +//! +//! At the `PlatformWalletPersistence` trait boundary, this type +//! converts into `PersistenceError`: `LockPoisoned` keeps its +//! dedicated variant; everything else flows through +//! `PersistenceError::Backend { kind, source }` — `kind` is classified +//! by [`WalletStorageError::persistence_kind`] (Transient / Constraint / +//! Fatal) and `source` carries the boxed typed error so consumers can +//! walk `Error::source()` to the underlying `rusqlite` payload. + +use std::path::PathBuf; + +use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; + +use crate::sqlite::util::safe_cast::SafeCastTarget; + +/// Which automatic-backup operation was attempted when the +/// configured backup directory was missing or otherwise unwritable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum AutoBackupOperation { + #[error("open (pending migration)")] + OpenMigration, + #[error("delete_wallet")] + DeleteWallet, + #[error("restore_from")] + Restore, +} + +/// Errors produced by the wallet-storage SQLite backend. +/// +/// `SqlitePersisterError` is preserved as a deprecated alias for one +/// cycle; new code should use `WalletStorageError`. +#[derive(Debug, thiserror::Error)] +pub enum WalletStorageError { + /// File-system I/O error reaching the database or backup files. + #[error("io error")] + Io(#[from] std::io::Error), + + /// Error from rusqlite — covers SQL errors, busy timeouts, and + /// schema-level failures alike. The inner `rusqlite::Error` + /// already discriminates between them. + #[error("sqlite error")] + Sqlite(#[from] rusqlite::Error), + + /// Refinery migration runner failure. + #[error("migration error")] + Migration(#[from] refinery::Error), + + /// `PRAGMA integrity_check` ran successfully but reported a + /// non-`ok` result. `report` carries SQLite's own diagnostic + /// text — not a user-facing message, not a stringified source. + /// May be multi-line (`\n`-joined): SQLite returns one row per + /// detected problem and the helper preserves every line. + #[error("integrity check failed: {report}")] + IntegrityCheckFailed { report: String }, + + /// Failed to even run the integrity-check pragma. + #[error("integrity check could not run")] + IntegrityCheckRunFailed { + #[source] + source: rusqlite::Error, + }, + + /// Cannot open the candidate source database file (most likely + /// not a SQLite database at all, or bytes are torn). + #[error("cannot open candidate source database")] + SourceOpenFailed { + #[source] + source: rusqlite::Error, + }, + + /// Source backup file lacks the `refinery_schema_history` table — + /// it isn't a wallet-storage database. + #[error("source backup is missing schema_history (not a platform-wallet-storage database)")] + SchemaHistoryMissing, + + /// Source backup carries a schema version beyond what this build + /// can apply. + #[error( + "source backup schema version {found} is beyond the supported maximum {max_supported}" + )] + SchemaVersionUnsupported { found: i64, max_supported: i64 }, + + /// A destructive operation needed an automatic backup but the + /// configuration disabled them. + #[error("auto-backup is disabled for operation: {operation}")] + AutoBackupDisabled { operation: AutoBackupOperation }, + + /// The configured auto-backup directory could not be created or + /// written to. + #[error("auto-backup directory {} could not be prepared", dir.display())] + AutoBackupDirUnwritable { + dir: PathBuf, + #[source] + source: std::io::Error, + }, + + /// `delete_wallet` (or another wallet-id-keyed operation) was + /// called with an id that has no matching `wallet_metadata` row. + #[error("wallet not found: {}", hex::encode(wallet_id))] + WalletNotFound { wallet_id: [u8; 32] }, + + /// A changeset entry named a `wallet_id` different from the wallet + /// the flush is scoped to — writing it would mis-file the row under + /// the wrong parent. + #[error( + "wallet id mismatch: entry names {} but flush is scoped to {}", + hex::encode(found), + hex::encode(expected) + )] + WalletIdMismatch { expected: [u8; 32], found: [u8; 32] }, + + /// A previous holder of an internal mutex panicked. Maps to the + /// trait-level [`PersistenceError::LockPoisoned`] so callers can + /// still pattern-match the boundary variant cleanly. + #[error("persister lock poisoned")] + LockPoisoned, + + /// `restore_from` tried to take a SQLite-native `BEGIN EXCLUSIVE` + /// on the destination and a peer (another `SqlitePersister`, a + /// bare `rusqlite::Connection`, the CLI) is holding it busy + /// beyond `busy_timeout`. + #[error("restore destination is locked or in use")] + RestoreDestinationLocked, + + /// A wallet-id hex string couldn't be parsed. + #[error("invalid wallet id: bad hex")] + InvalidWalletIdHex { + #[source] + source: hex::FromHexError, + }, + + /// A wallet-id hex string had the wrong length (must be 64 chars + /// for a 32-byte id). + #[error("invalid wallet id length: expected 64 hex chars, got {actual}")] + InvalidWalletIdLength { actual: usize }, + + /// A `SqlitePersisterConfig` field carries an unsupported value + /// (e.g. `synchronous = Off`). The `reason` is a compile-time + /// `&'static str` constant naming the rejected setting. + #[error("invalid configuration: {reason}")] + ConfigInvalid { reason: &'static str }, + + /// bincode-serde refused to encode a value (typically because + /// the value's serde representation needs `deserialize_any`-style + /// dispatch — see dpp's `IdentityPublicKey` workaround). + #[error("bincode encode error")] + BincodeEncode { + #[source] + source: bincode::error::EncodeError, + }, + + /// bincode-serde refused to decode a payload. + #[error("bincode decode error")] + BincodeDecode { + #[source] + source: bincode::error::DecodeError, + }, + + /// A typed-column decode failed (e.g. outpoint had the wrong + /// length, or a column held a value the schema doesn't recognise). + #[error("blob/column decode failed: {reason}")] + BlobDecode { reason: &'static str }, + + /// A typed-column decode failed because an underlying + /// `dashcore::hashes` deserialisation rejected the bytes. + #[error("hash decode failed")] + HashDecode { + #[source] + source: dashcore::hashes::Error, + }, + + /// A `dashcore` consensus encode/decode failed. + #[error("dashcore consensus encoding failed")] + ConsensusCodec { + #[source] + source: dashcore::consensus::encode::Error, + }, + + /// The CLI's `backup` subcommand refuses to overwrite an existing + /// destination file. + #[error("backup destination already exists: {}", path.display())] + BackupDestinationExists { path: PathBuf }, + + /// An `identity_keys` upsert entry's `(identity_id, key_id, + /// wallet_id)` fields disagreed with the map key / flush scope the + /// typed columns are bound from — persisting it would leave the + /// typed columns and the serialized blob describing different rows. + #[error("identity key entry fields disagree with its map key / wallet scope")] + IdentityKeyEntryMismatch, + + /// An `asset_locks` row's typed-column `(outpoint, account_index)` + /// disagreed with the lifecycle blob's `(out_point, account_index)`. + /// Mirrors `IdentityKeyEntryMismatch` — a torn write, partial + /// migration, or restored corruption that survives the per-row + /// `integrity_check` is still rejected at decode time rather than + /// mis-bucketing the lock under the wrong account. + #[error( + "asset_lock entry fields disagree with typed columns \ + (typed outpoint={typed_outpoint}, blob outpoint={blob_outpoint}, \ + typed account_index={typed_account_index}, blob account_index={blob_account_index})" + )] + AssetLockEntryMismatch { + typed_outpoint: String, + blob_outpoint: String, + typed_account_index: u32, + blob_account_index: u32, + }, + + /// A blob payload exceeded the configured allocation cap during + /// decode. Surfaced separately from generic [`Self::BlobDecode`] so + /// operators can distinguish a hostile or corrupted oversize blob + /// from a structural decode failure. Defaults to 16 MiB — well + /// above any legitimate per-row payload. + #[error("blob exceeded decode size limit ({len_bytes} bytes > {limit_bytes} byte cap)")] + BlobTooLarge { + len_bytes: usize, + limit_bytes: usize, + }, + + /// `PRAGMA foreign_keys = ON` was issued on open but the read-back + /// reported the constraint enforcement is still off — the linked + /// SQLite build silently ignores the pragma (no FK support compiled + /// in). Hard-error at open rather than letting orphan rows accrue. + #[error("SQLite foreign-key enforcement could not be enabled on this connection")] + ForeignKeysNotEnforced, + + /// A value couldn't be cast to the database's native i64 + /// representation without losing magnitude. + #[error("integer overflow casting `{field}` (value={value}) to {target}")] + IntegerOverflow { + field: &'static str, + value: u64, + target: SafeCastTarget, + }, + + /// A migration declined to run because the source database carries + /// legacy rows that an operator must clear manually first (e.g. V002 + /// refuses to migrate `token_balances` rows written under the + /// `WalletId::default()` sentinel). `table` names the offending + /// table; `count` is the number of offending rows discovered by + /// the migration guard. + #[error( + "migration requires manual cleanup: {table} has {count} legacy row(s) that must be \ + dropped before re-running migrations" + )] + MigrationRequiresManualCleanup { table: &'static str, count: i64 }, + + /// Flush failed transiently (e.g. `SQLITE_BUSY` / `SQLITE_LOCKED`) + /// for `wallet_id`. The buffered changeset has been restored — the + /// next `flush(wallet_id)` will retry the same data merged with + /// anything stored in between. Callers should back off and retry + /// rather than dropping state. + /// + /// **Use exponential backoff; do NOT tight-loop on this error** — + /// hammering the persister at full speed turns a transient lock + /// contention into a hot CPU spin and delays whoever holds the + /// lock from releasing it. + /// + /// The variant name `FlushRetryable` is intentionally embedded in + /// the `Display` output so operators grepping production logs can + /// match on the variant directly. + #[error( + "FlushRetryable: flush failed transiently for wallet {}; buffer preserved for retry", + hex::encode(wallet_id) + )] + FlushRetryable { + wallet_id: [u8; 32], + #[source] + source: rusqlite::Error, + }, +} + +/// Deprecated alias preserved for one cycle. Switch downstream +/// references to [`WalletStorageError`]. +#[deprecated(since = "3.1.0-dev.1", note = "renamed to WalletStorageError")] +pub type SqlitePersisterError = WalletStorageError; + +impl From for PersistenceError { + fn from(err: WalletStorageError) -> Self { + match err { + WalletStorageError::LockPoisoned => PersistenceError::LockPoisoned, + other => { + let kind = other.persistence_kind(); + PersistenceError::backend_with_kind(kind, other) + } + } + } +} + +impl WalletStorageError { + /// Construct a typed `BlobDecode` error from a static reason. + /// Used by schema modules that hit a structural decode error + /// (e.g. an outpoint column that isn't 36 bytes). + pub(crate) fn blob_decode(reason: &'static str) -> Self { + Self::BlobDecode { reason } + } + + /// `true` when the underlying failure is safe to retry — the + /// caller should preserve in-flight state and call again. + /// Transient codes (ATOM-008 / A-4): + /// - `DatabaseBusy` / `DatabaseLocked`: contention. + /// - `DiskFull`: operator clears disk space. + /// - `SystemIoFailure`: kernel-level I/O blip (NFS, raid rebuild). + /// - `OutOfMemory`: transient memory pressure. + /// + /// All four classes are recoverable environmental conditions — + /// dropping buffered state on them would be data loss for a + /// problem the operator (or kernel) clears on its own. + /// + /// The OUTER match on `WalletStorageError` is intentionally + /// wildcard-free: the enum MUST NOT gain `#[non_exhaustive]` so a + /// future variant forces the author to classify it here. The + /// INNER match on `rusqlite::ErrorCode` uses a wildcard because + /// `ErrorCode` is `#[non_exhaustive]` upstream. + pub fn is_transient(&self) -> bool { + use rusqlite::ErrorCode; + match self { + Self::Sqlite(rusqlite::Error::SqliteFailure(e, _)) => matches!( + e.code, + ErrorCode::DatabaseBusy + | ErrorCode::DatabaseLocked + | ErrorCode::DiskFull + | ErrorCode::SystemIoFailure + | ErrorCode::OutOfMemory + ), + Self::FlushRetryable { .. } => true, + // Every other rusqlite variant — non-`SqliteFailure` (e.g. + // `ToSqlConversionFailure`, `InvalidColumnIndex`) — is a + // logic bug, not a contention failure. + Self::Sqlite(_) => false, + Self::Io(_) + | Self::Migration(_) + | Self::IntegrityCheckFailed { .. } + | Self::IntegrityCheckRunFailed { .. } + | Self::SourceOpenFailed { .. } + | Self::SchemaHistoryMissing + | Self::SchemaVersionUnsupported { .. } + | Self::AutoBackupDisabled { .. } + | Self::AutoBackupDirUnwritable { .. } + | Self::WalletNotFound { .. } + | Self::WalletIdMismatch { .. } + // TODO(qa): TC-P2-008 — `LockPoisoned` is classified as + // fatal here, but the end-to-end mutex-poison flow has no + // automated test (the spec deferred it as race-prone — a + // panicking thread + join is hard to reproduce + // deterministically). Manual verification only via the + // table-driven test in `tests/sqlite_error_classification`. + // If you change this classification, re-derive + // `handle_flush_error`'s fatal-branch behavior to match. + | Self::LockPoisoned + | Self::RestoreDestinationLocked + | Self::InvalidWalletIdHex { .. } + | Self::InvalidWalletIdLength { .. } + | Self::ConfigInvalid { .. } + | Self::BincodeEncode { .. } + | Self::BincodeDecode { .. } + | Self::BlobDecode { .. } + | Self::HashDecode { .. } + | Self::ConsensusCodec { .. } + | Self::BackupDestinationExists { .. } + | Self::ForeignKeysNotEnforced + | Self::IdentityKeyEntryMismatch + | Self::AssetLockEntryMismatch { .. } + | Self::BlobTooLarge { .. } + | Self::MigrationRequiresManualCleanup { .. } + | Self::IntegerOverflow { .. } => false, + } + } + + /// Trait-boundary classification for the + /// [`PersistenceError::Backend`] kind field (CODE-004). Three + /// classes: + /// + /// - [`PersistenceErrorKind::Transient`] — every variant where + /// [`Self::is_transient`] is `true`. Caller MAY retry. + /// - [`PersistenceErrorKind::Constraint`] — SQL constraint / + /// FK / NOT NULL / UNIQUE / PK / CHECK violations. Schema / + /// integrity failure; caller bug, not infra. + /// - [`PersistenceErrorKind::Fatal`] — everything else. + /// + /// [`Self::LockPoisoned`] is handled by the `From` impl directly + /// (it maps to [`PersistenceError::LockPoisoned`] rather than + /// flowing through `Backend`). + pub fn persistence_kind(&self) -> PersistenceErrorKind { + use rusqlite::ErrorCode; + if self.is_transient() { + return PersistenceErrorKind::Transient; + } + match self { + Self::Sqlite(rusqlite::Error::SqliteFailure(e, _)) + if matches!(e.code, ErrorCode::ConstraintViolation) => + { + PersistenceErrorKind::Constraint + } + // Refinery surfaces FK / constraint problems through + // rusqlite; if that path leaks through here the typed + // variant lives in `Self::Migration`, which we leave as + // `Fatal` since a migration failure isn't a caller bug. + _ => PersistenceErrorKind::Fatal, + } + } + + /// Short, lowercase, snake-case tag for tracing fields. One tag + /// per variant family — readers grep for these in production + /// logs. + pub fn error_kind_str(&self) -> &'static str { + use rusqlite::ErrorCode; + match self { + Self::Sqlite(rusqlite::Error::SqliteFailure(e, _)) => match e.code { + ErrorCode::DatabaseBusy => "sqlite_busy", + ErrorCode::DatabaseLocked => "sqlite_locked", + ErrorCode::DiskFull => "sqlite_disk_full", + ErrorCode::SystemIoFailure => "sqlite_io_failure", + ErrorCode::OutOfMemory => "sqlite_out_of_memory", + _ => "sqlite_other", + }, + Self::Sqlite(_) => "sqlite_other", + Self::FlushRetryable { .. } => "flush_retryable", + Self::Io(_) => "io", + Self::Migration(_) => "migration", + Self::IntegrityCheckFailed { .. } => "integrity_check_failed", + Self::IntegrityCheckRunFailed { .. } => "integrity_check_run_failed", + Self::SourceOpenFailed { .. } => "source_open_failed", + Self::SchemaHistoryMissing => "schema_history_missing", + Self::SchemaVersionUnsupported { .. } => "schema_version_unsupported", + Self::AutoBackupDisabled { .. } => "auto_backup_disabled", + Self::AutoBackupDirUnwritable { .. } => "auto_backup_dir_unwritable", + Self::WalletNotFound { .. } => "wallet_not_found", + Self::WalletIdMismatch { .. } => "wallet_id_mismatch", + Self::LockPoisoned => "lock_poisoned", + Self::RestoreDestinationLocked => "restore_destination_locked", + Self::InvalidWalletIdHex { .. } => "invalid_wallet_id_hex", + Self::InvalidWalletIdLength { .. } => "invalid_wallet_id_length", + Self::ConfigInvalid { .. } => "config_invalid", + Self::BincodeEncode { .. } => "bincode_encode", + Self::BincodeDecode { .. } => "bincode_decode", + Self::BlobDecode { .. } => "blob_decode", + Self::HashDecode { .. } => "hash_decode", + Self::ConsensusCodec { .. } => "consensus_codec", + Self::BackupDestinationExists { .. } => "backup_destination_exists", + Self::ForeignKeysNotEnforced => "foreign_keys_not_enforced", + Self::IdentityKeyEntryMismatch => "identity_key_entry_mismatch", + Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", + Self::BlobTooLarge { .. } => "blob_too_large", + Self::IntegerOverflow { .. } => "integer_overflow", + Self::MigrationRequiresManualCleanup { .. } => "migration_requires_manual_cleanup", + } + } +} + +impl From for WalletStorageError { + fn from(source: bincode::error::EncodeError) -> Self { + Self::BincodeEncode { source } + } +} + +impl From for WalletStorageError { + fn from(source: bincode::error::DecodeError) -> Self { + Self::BincodeDecode { source } + } +} + +impl From for WalletStorageError { + fn from(source: dashcore::hashes::Error) -> Self { + Self::HashDecode { source } + } +} + +impl From for WalletStorageError { + fn from(source: dashcore::consensus::encode::Error) -> Self { + Self::ConsensusCodec { source } + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs new file mode 100644 index 00000000000..3790ceb9e22 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -0,0 +1,215 @@ +//! Schema migration plumbing. +//! +//! Embeds every Rust migration under `migrations/` at compile time +//! (see `refinery::embed_migrations!`). The `run` function applies any +//! pending migrations to the supplied connection. + +use rusqlite::OptionalExtension; + +use crate::sqlite::error::WalletStorageError; + +// `embed_migrations!` generates a `migrations` module with a `runner()` +// function. The path is relative to the crate root (where `Cargo.toml` +// lives). +refinery::embed_migrations!("./migrations"); + +/// Apply every pending migration to `conn`. +pub fn run(conn: &mut rusqlite::Connection) -> Result { + migrations::runner().run(conn) +} + +/// Apply migrations on behalf of [`crate::sqlite::persister::SqlitePersister::open`], +/// re-classifying the V002 sentinel-row CHECK failure into a typed +/// [`WalletStorageError::MigrationRequiresManualCleanup`] so operators +/// see what the migration refused on instead of a raw rusqlite error. +/// +/// V002 reshapes identity-owned tables, which involves +/// `DROP TABLE identities` while child tables (`identity_keys`, +/// `dashpay_profiles`, etc.) carry `ON DELETE CASCADE` FK clauses +/// pointing at it. SQLite's drop-of-parent semantics fire those +/// CASCADE clauses when FK enforcement is on, wiping the rows we're +/// trying to migrate. FK enforcement cannot be toggled inside a +/// transaction, so the pragma is flipped off here for the migration +/// window and re-enabled (with assertion) right after — matching +/// `open_conn`'s normal invariant. +pub(crate) fn run_for_open( + conn: &mut rusqlite::Connection, +) -> Result { + crate::sqlite::conn::set_foreign_keys(conn, false)?; + let result = run(conn); + // Always restore FK enforcement, even on migration error, so the + // caller's connection is in the documented state. + crate::sqlite::conn::enforce_foreign_keys(conn)?; + match result { + Ok(r) => Ok(r), + Err(e) => { + // The V002 guard uses a CHECK on the temp table + // `_v002_sentinel_rows_must_be_zero`; SQLite surfaces that + // as a ConstraintViolation whose message names the table. + if migration_failure_is_sentinel(&e) { + // Re-query the live `token_balances` table for the + // count so the typed error carries the actual number + // of offending rows. The CHECK message itself does not + // expose it. + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM token_balances \ + WHERE wallet_id = X'0000000000000000000000000000000000000000000000000000000000000000'", + [], + |row| row.get(0), + ) + .unwrap_or(0); + return Err(WalletStorageError::MigrationRequiresManualCleanup { + table: "token_balances", + count, + }); + } + Err(WalletStorageError::Migration(e)) + } + } +} + +/// Recognise the V002 sentinel-guard failure by walking the error +/// chain for a `ConstraintViolation` whose message names the guard +/// table. +fn migration_failure_is_sentinel(err: &refinery::Error) -> bool { + let mut source: Option<&dyn std::error::Error> = Some(err); + while let Some(s) = source { + // SQLite surfaces the failing CHECK by predicate text + // (`CHECK constraint failed: sentinel_count = 0`), not by table + // name. `sentinel_count` is unique to the V002 guard temp + // table; matching on the column name keeps the detector tight + // without false positives. + if let Some(rusqlite::Error::SqliteFailure(code, msg)) = s.downcast_ref::() + { + if matches!(code.code, rusqlite::ErrorCode::ConstraintViolation) + && msg.as_deref().is_some_and(|m| m.contains("sentinel_count")) + { + return true; + } + } + source = s.source(); + } + false +} + +/// Return a fresh refinery [`Runner`](refinery::Runner) seeded with the +/// embedded migration list. Used by tests that need to apply a subset +/// of migrations via [`refinery::Runner::set_target`]. +pub fn runner() -> refinery::Runner { + migrations::runner() +} + +/// Highest migration version this binary knows how to apply. Used by +/// both `SqlitePersister::open` (CMT-005) and `backup::restore_from` +/// (CMT-001 / CMT-010) to refuse forward-version databases. +pub fn max_supported_version() -> i64 { + embedded_migrations() + .iter() + .map(|(v, _)| *v as i64) + .max() + .unwrap_or(0) +} + +/// Returns true if the `refinery_schema_history` table exists on this +/// connection. Used by `open`, `restore_from`, and `count_pending` to +/// distinguish "fresh DB" (no migrations applied yet) from +/// "pre-existing DB" (carries refinery history). +pub(crate) fn has_schema_history(conn: &rusqlite::Connection) -> Result { + let exists = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + Ok(exists) +} + +/// Refuse to operate on a database whose `refinery_schema_history` +/// MAX(version) exceeds [`max_supported_version`]. Returns +/// [`WalletStorageError::SchemaVersionUnsupported`] in that case. +/// +/// Quietly succeeds when the table is absent (caller decides whether a +/// missing schema-history is itself an error — `restore_from` rejects +/// it, `open` treats it as "brand-new DB about to be migrated"). +pub fn assert_schema_version_supported( + conn: &rusqlite::Connection, +) -> Result<(), WalletStorageError> { + if !has_schema_history(conn)? { + return Ok(()); + } + let source_version: Option = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .optional()? + .flatten(); + let max_supported = max_supported_version(); + if let Some(v) = source_version { + if v > max_supported { + return Err(WalletStorageError::SchemaVersionUnsupported { + found: v, + max_supported, + }); + } + } + Ok(()) +} + +/// List `(version, name)` of every embedded migration. Used by tests and +/// the migration-drift hash check (TC-029). +pub fn embedded_migrations() -> Vec<(i32, String)> { + migrations::runner() + .get_migrations() + .iter() + .map(|m| (m.version(), m.name().to_string())) + .collect() +} + +/// SHA-256 over `(version, name)` of every embedded migration in version +/// order. Pinning this in tests catches edits to committed migrations +/// (forbidden by NFR-8 append-only policy). +pub fn embedded_migrations_fingerprint() -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut entries = embedded_migrations(); + entries.sort_by_key(|(v, _)| *v); + let mut hasher = Sha256::new(); + for (v, name) in entries { + hasher.update(v.to_be_bytes()); + hasher.update([0u8]); + hasher.update(name.as_bytes()); + hasher.update([0u8]); + } + hasher.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + /// TC-CODE-027-1: helper returns false on a brand-new in-memory DB + /// (no `refinery_schema_history`), and true after the table is + /// created. + #[test] + fn has_schema_history_distinguishes_fresh_vs_migrated() { + let conn = Connection::open_in_memory().unwrap(); + assert!( + !has_schema_history(&conn).unwrap(), + "fresh in-memory DB has no schema-history table" + ); + conn.execute( + "CREATE TABLE refinery_schema_history (version INTEGER PRIMARY KEY)", + [], + ) + .unwrap(); + assert!( + has_schema_history(&conn).unwrap(), + "schema-history table is present after creation" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs new file mode 100644 index 00000000000..a11b535afe9 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs @@ -0,0 +1,24 @@ +//! SQLite-backed persistence for `platform-wallet`. +//! +//! Implements [`PlatformWalletPersistence`](platform_wallet::changeset::PlatformWalletPersistence) +//! with a per-wallet in-memory buffer, atomic per-wallet flushes, online +//! backup, retention, and a maintenance CLI. The submodules form the +//! internal layout — most callers reach for the re-exports at the crate +//! root instead. + +pub mod backup; +pub mod buffer; +pub mod config; +pub(crate) mod conn; +pub mod error; +pub mod migrations; +pub mod persister; +pub mod schema; +pub mod util; + +pub use config::{ + default_auto_backup_dir, FlushMode, JournalMode, SqlitePersisterConfig, Synchronous, +}; +#[allow(deprecated)] +pub use error::{AutoBackupOperation, SqlitePersisterError, WalletStorageError}; +pub use persister::{PruneReport, RetentionPolicy, SqlitePersister}; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs new file mode 100644 index 00000000000..4f64467d6f5 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -0,0 +1,1266 @@ +//! [`SqlitePersister`] — the canonical `PlatformWalletPersistence` impl. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use rusqlite::{Connection, OptionalExtension}; + +use platform_wallet::changeset::{ + ClientStartState, CommitReport, DeleteWalletReport, Merge, PersistenceError, + PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::backup::{self, BackupKind}; +use crate::sqlite::buffer::Buffer; +use crate::sqlite::config::{FlushMode, SqlitePersisterConfig, Synchronous}; +use crate::sqlite::error::{AutoBackupOperation, WalletStorageError}; +use crate::sqlite::schema::{self, count_rows_for_wallet_sql, PER_WALLET_TABLES}; +use crate::sqlite::util::permissions::apply_secure_permissions; +use crate::sqlite::util::safe_cast; + +/// Sub-areas of `ClientStartState` that `load()` does not yet +/// reconstruct (blocked on upstream `Wallet::from_persisted`). +/// +/// Surfaced via the structured `tracing::info!` summary on every +/// `load()` (`unimplemented` + `wallets_pending_rehydration` fields). +pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = &["ClientStartState::wallets"]; + +/// Outcome of a `prune_backups` call. +/// +/// Invariant: `kept == total_eligible - removed.len()`. A file is +/// counted as `kept` if it survived the policy (retained-by-rule) OR +/// if `remove_file` failed (`failed_removals` is a subset of `kept`). +/// Either way, the file is still on disk after this call. +#[derive(Debug)] +pub struct PruneReport { + /// Paths that were unlinked, sorted oldest-first by filename + /// timestamp. + pub removed: Vec, + /// Files still on disk after this call. Equals + /// `total_eligible - removed.len()` and includes every + /// `failed_removals` entry — a file that couldn't be unlinked is + /// still on disk and therefore "kept". + pub kept: usize, + /// Files we tried to remove but couldn't, paired with the + /// underlying `io::Error`. Returned as part of `Ok(report)` so a + /// partial failure surfaces every removed AND every failed entry + /// — the caller can re-invoke `prune_backups` to retry just the + /// stragglers. ATOM-011 / A-6. + pub failed_removals: Vec<(PathBuf, std::io::Error)>, +} + +/// Retention policy for `prune_backups`. +/// +/// **AND-semantics**: a file is kept iff it satisfies BOTH rules. A +/// policy with `keep_last_n = Some(3)` and `max_age = Some(30d)` keeps +/// at most the three newest backups AND only those younger than 30 +/// days — a four-day-old backup that's the fifth-newest is removed. +/// `RetentionPolicy::default()` (both `None`) keeps every file. +#[derive(Debug, Clone, Copy, Default)] +pub struct RetentionPolicy { + pub keep_last_n: Option, + pub max_age: Option, +} + +impl RetentionPolicy { + pub fn keep_last(n: usize) -> Self { + Self { + keep_last_n: Some(n), + max_age: None, + } + } + pub fn older_than(d: std::time::Duration) -> Self { + Self { + keep_last_n: None, + max_age: Some(d), + } + } +} + +/// SQLite-backed `PlatformWalletPersistence`. +pub struct SqlitePersister { + config: SqlitePersisterConfig, + // INTENTIONAL(CODE-001): single connection serializes reads through + // the write lock. Acceptable for current workload (per-wallet + // operations, small read footprint); revisit if read contention + // becomes measurable. Splitting into a read-only `r2d2` pool over + // the same WAL-mode file is the planned follow-up. + conn: Arc>, + buffer: Buffer, + /// Test-only one-shot injector for `flush_inner`. Lives on the + /// struct so `force_next_flush_to_fail` can survive across `&self` + /// calls. Production builds keep the slot but never write to it + /// (no public setter outside `#[cfg(any(test, feature = "__test-helpers"))]`). + #[cfg(any(test, feature = "__test-helpers"))] + primed_flush_error: Mutex>, + /// Test-only one-shot callback fired by `delete_wallet_inner` + /// between the pre-delete backup snapshot and the cascade + /// EXCLUSIVE acquisition. Lets cross-process delete-race tests + /// inject a peer mutation in the otherwise-tiny window left open + /// by rusqlite's Backup-API constraint (no source-side write tx). + #[cfg(any(test, feature = "__test-helpers"))] + post_backup_hook: Mutex>>, + /// Test-only one-shot injection consumed by `delete_wallet`'s + /// pre-flush phase. Lets TC-CODE-006-2 assert the buffer-restore + /// and skip-backup semantics without provoking a real SQL error. + #[cfg(any(test, feature = "__test-helpers"))] + primed_pre_flush_error: Mutex>, +} + +impl SqlitePersister { + /// Open or create the SQLite DB at `config.path`. Applies pragmas, + /// asserts integrity on a pre-existing DB, runs migrations, + /// optionally takes a pre-migration auto-backup. + /// + /// # Errors + /// + /// - [`WalletStorageError::ConfigInvalid`] — rejected + /// [`SqlitePersisterConfig`] field (e.g. `synchronous = Off`). + /// - [`WalletStorageError::Io`] (kind `NotFound`) — the parent of + /// `config.path` does not exist. The persister refuses to create + /// parent directories silently (NFR-6). + /// - [`WalletStorageError::ForeignKeysNotEnforced`] — the linked + /// SQLite build silently ignores `PRAGMA foreign_keys = ON` + /// (no FK support compiled in). + /// - [`WalletStorageError::SchemaVersionUnsupported`] — the DB + /// carries a `refinery_schema_history` row beyond what this + /// binary can apply. Symmetric with `restore_from`'s gate. + /// - [`WalletStorageError::IntegrityCheckFailed`] (ATOM-013) — + /// `PRAGMA integrity_check` on the pre-existing DB returned a + /// non-`ok` report. Raised BEFORE migrations alter the file so + /// corruption is never silently migrated. + /// - [`WalletStorageError::Migration`] — refinery failed mid-run. + /// - [`WalletStorageError::AutoBackupDirUnwritable`] / + /// [`WalletStorageError::AutoBackupDisabled`] — the + /// pre-migration auto-backup couldn't materialise. + pub fn open(config: SqlitePersisterConfig) -> Result { + validate_config(&config)?; + if let Some(parent) = config.path.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + // Parent dir must exist — refuse silently creating it + // to keep "bad path" errors typed (NFR-6). + return Err(WalletStorageError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("database parent directory not found: {}", parent.display()), + ))); + } + } + + // Open the connection AND apply pragmas before checking for + // pending migrations so the integrity probe sees the configured + // journal mode and busy timeout. `open_conn` enables foreign-key + // enforcement and asserts the read-back before any write lands. + let mut conn = + crate::sqlite::conn::open_conn(&config.path, crate::sqlite::conn::Access::ReadWrite)?; + // SEC-011: chmod 600 on Unix so a freshly created DB doesn't + // inherit a wider mode from the process umask. Idempotent on + // re-open. + apply_secure_permissions(&config.path)?; + apply_pragmas(&mut conn, &config)?; + + // Determine whether `schema_history` exists *before* we run + // migrations — that's the signal for "is this DB pre-existing + // or brand-new?" (FR-15 vs FR-16). Errors from the underlying + // query are propagated, not silently treated as "no history". + let had_schema_history = crate::sqlite::migrations::has_schema_history(&conn)?; + // ATOM-013 (A-8): run integrity_check on a pre-existing DB + // BEFORE migrations alter it. Bit-rot or escaped-WAL corruption + // detected here surfaces as the typed `IntegrityCheckFailed` + // before any schema mutation lands. The pre-migration auto- + // backup snapshots the live state, so without this gate a + // corrupt DB gets backed up and migrated in the same pass — + // making the auto-backup useless for rollback. + if had_schema_history { + crate::sqlite::backup::run_integrity_check(&conn, |report| { + WalletStorageError::IntegrityCheckFailed { report } + })?; + } + // CMT-005: refuse to open a DB produced by a newer binary — + // refinery's run() would no-op on pending_count==0, after which + // blob decoders would see forward-schema bytes. Symmetric with + // restore_from's max-version gate (both call the same helper). + if had_schema_history { + crate::sqlite::migrations::assert_schema_version_supported(&conn)?; + } + let pending = crate::sqlite::migrations::embedded_migrations(); + let pending_count = if had_schema_history { + count_pending(&mut conn, &pending)? + } else { + pending.len() + }; + + if pending_count > 0 && had_schema_history { + let from = current_schema_version(&conn)?.unwrap_or(0); + let to = pending.iter().map(|(v, _)| *v).max().unwrap_or(from); + run_auto_backup( + &conn, + config.auto_backup_dir.as_deref(), + BackupKind::PreMigration { from, to }, + AutoBackupOperation::OpenMigration, + )?; + } + + // Apply migrations. `run_for_open` re-classifies the V002 + // sentinel-row CHECK failure into a typed + // `MigrationRequiresManualCleanup` so operators see what + // refused instead of a bare rusqlite error. + let _report = crate::sqlite::migrations::run_for_open(&mut conn)?; + + Ok(Self { + config, + conn: Arc::new(Mutex::new(conn)), + buffer: Buffer::new(), + #[cfg(any(test, feature = "__test-helpers"))] + primed_flush_error: Mutex::new(None), + #[cfg(any(test, feature = "__test-helpers"))] + post_backup_hook: Mutex::new(None), + #[cfg(any(test, feature = "__test-helpers"))] + primed_pre_flush_error: Mutex::new(None), + }) + } + + /// Take a manual online backup. `dest` may be a directory (auto- + /// named `wallet-.db`) or a full file path (must not pre-exist). + pub fn backup_to(&self, dest: &Path) -> Result { + let resolved = if dest.is_dir() { + dest.join(backup::manual_backup_filename()) + } else { + if dest.exists() { + return Err(WalletStorageError::BackupDestinationExists { + path: dest.to_path_buf(), + }); + } + dest.to_path_buf() + }; + let conn = self.conn()?; + backup::run_to(&conn, &resolved)?; + Ok(resolved.canonicalize().unwrap_or(resolved)) + } + + /// Restore a backup over `dest_db_path`. Destination must not be + /// open in this process. Associated function — no `&self`. + /// + /// Takes a pre-restore auto-backup of the live destination + /// database (when `auto_backup_dir` is `Some`) before persisting + /// the staged source. Refuses with + /// [`WalletStorageError::AutoBackupDisabled`] when the directory + /// is `None`; pass `auto_backup_dir = None` only via the CLI's + /// `--no-auto-backup` flag (or directly through + /// [`restore_from_skip_backup`](Self::restore_from_skip_backup)). + pub fn restore_from( + dest_db_path: &Path, + src_backup: &Path, + auto_backup_dir: Option<&Path>, + ) -> Result<(), WalletStorageError> { + Self::restore_from_inner(dest_db_path, src_backup, auto_backup_dir, false) + } + + /// Restore a backup over `dest_db_path` WITHOUT taking a + /// pre-restore auto-backup. + /// + /// Library consumers should prefer [`restore_from`](Self::restore_from) + /// — it's safe by default. This entry point exists so the CLI's + /// `--no-auto-backup` flag can deliver on its name regardless of + /// `auto_backup_dir`. + pub fn restore_from_skip_backup( + dest_db_path: &Path, + src_backup: &Path, + ) -> Result<(), WalletStorageError> { + Self::restore_from_inner(dest_db_path, src_backup, None, true) + } + + fn restore_from_inner( + dest_db_path: &Path, + src_backup: &Path, + auto_backup_dir: Option<&Path>, + skip_backup: bool, + ) -> Result<(), WalletStorageError> { + if !skip_backup && dest_db_path.exists() { + let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { + operation: AutoBackupOperation::Restore, + })?; + // Open the destination read-only just long enough to + // page-stream a snapshot to disk under auto_backup_dir. + let dest_conn = crate::sqlite::conn::open_conn( + dest_db_path, + crate::sqlite::conn::Access::ReadOnly, + )?; + run_auto_backup( + &dest_conn, + Some(dir), + BackupKind::PreRestore, + AutoBackupOperation::Restore, + )?; + drop(dest_conn); + } + backup::restore_from(dest_db_path, src_backup) + } + + /// Apply retention to a directory of `wallet-*.db` (and/or + /// `pre-*-*.db`) files. + pub fn prune_backups( + &self, + dir: &Path, + policy: RetentionPolicy, + ) -> Result { + backup::prune(dir, policy) + } + + /// Cascade-delete every row owned by `wallet_id`. Takes a + /// pre-delete auto-backup before the cascade and refuses if + /// `auto_backup_dir` is `None` (FR-18). For the library-API, + /// safe-by-default route. + /// + /// To skip the auto-backup explicitly — wired up by the CLI's + /// `--no-auto-backup` — call + /// [`delete_wallet_skip_backup`](Self::delete_wallet_skip_backup). + /// + /// # Racing stores + /// + /// N-4: calls to `store(wallet_id, ...)` for the same wallet while + /// `delete_wallet` is in progress will be **discarded** after the + /// delete commits. The store call may return `Ok(())` (in + /// `FlushMode::Manual` it lands in the buffer), but its data does + /// not survive the delete — the post-commit re-drain inside + /// `delete_wallet` removes any buffered changeset that arrived + /// during the delete window. Synchronize at the caller layer if + /// you need different semantics. + pub fn delete_wallet( + &self, + wallet_id: WalletId, + ) -> Result { + self.delete_wallet_inner(wallet_id, false) + } + + /// Cascade-delete every row owned by `wallet_id` WITHOUT taking + /// an auto-backup. + /// + /// Library consumers should prefer [`delete_wallet`](Self::delete_wallet) + /// — it's safe by default. This entry point exists so the CLI's + /// `--no-auto-backup` flag can deliver on its name regardless of + /// `auto_backup_dir`. Returns `DeleteWalletReport.backup_path = + /// None` to signal the backup was intentionally skipped. + pub fn delete_wallet_skip_backup( + &self, + wallet_id: WalletId, + ) -> Result { + self.delete_wallet_inner(wallet_id, true) + } + + fn delete_wallet_inner( + &self, + wallet_id: WalletId, + skip_backup: bool, + ) -> Result { + // CMT-008: acquire the connection mutex FIRST so concurrent + // in-process `store()` calls block on it. Cross-process peers + // (other rusqlite Connections / sibling `SqlitePersister`s) are + // excluded by `BEGIN EXCLUSIVE` below — the in-process mutex + // alone never gave that guarantee. + let mut conn = self.conn()?; + + // Drain the buffered changeset so a later flush can't + // resurrect the wallet, and so the wallet counts as existing + // even when its only state is buffered. Hold the drained value + // in `drained_slot` and only consume it AFTER tx.commit(). + let drained = self.buffer.take_for_flush(&wallet_id)?; + let had_buffered = drained.is_some(); + let drained_slot: std::cell::Cell> = + std::cell::Cell::new(drained); + + // Helper: any pre-commit failure must restore the changeset so + // we don't lose pending writes on a delete that didn't happen. + let restore_buffer = |slot: &std::cell::Cell>| { + if let Some(cs) = slot.take() { + if let Err(e) = self.buffer.restore(wallet_id, cs) { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error_kind = e.error_kind_str(), + "buffer restore failed during delete_wallet error path — changeset lost" + ); + } + } + }; + + let result: Result = (|| { + // Pre-flight existence check on the bare conn (no tx) so + // we don't waste a backup file on an unknown wallet. + let exists_pre_flush = conn + .query_row( + "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !had_buffered && !exists_pre_flush { + return Err(WalletStorageError::WalletNotFound { wallet_id }); + } + + // Test-only injector for TC-CODE-006-2 — force the pre- + // flush below to fail with the primed error without + // depending on a real SQL failure. Keeps the test free of + // FK-poisoning scaffolding. + #[cfg(any(test, feature = "__test-helpers"))] + let primed_pre_flush_error = self.consume_primed_pre_flush_error(); + + // CODE-006: flush the drained buffer to disk BEFORE + // `run_auto_backup` so the pre-delete snapshot includes + // every pending write. Without this the backup captures + // only already-persisted state and rollback-from-backup + // cannot recover the buffered (lost) data. + // + // The flush opens its own EXCLUSIVE tx and commits; + // `run_auto_backup` then runs against the freshly-flushed + // DB. On flush failure we restore the buffer via the outer + // `restore_buffer` helper and abort the delete — mirrors + // CMT-002. + // + // The cascade-side backup runs BEFORE the cascade's + // `BEGIN EXCLUSIVE` because rusqlite's `Backup::new` can't + // establish a backup whose source connection holds an + // active write tx on its own DB — `sqlite3_backup_step` + // would deadlock against the in-flight EXCLUSIVE. The + // post-EXCLUSIVE re-check below handles cross-process + // peers that mutate the wallet between snapshot and lock. + if let Some(cs) = drained_slot.take() { + #[cfg(any(test, feature = "__test-helpers"))] + if let Some(primed) = primed_pre_flush_error { + drained_slot.set(Some(cs)); + return Err(primed); + } + let pre_flush_tx = + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; + if let Err(e) = apply_changeset_to_tx(&pre_flush_tx, &wallet_id, &cs) { + let _ = pre_flush_tx.rollback(); + drained_slot.set(Some(cs)); + return Err(e); + } + if let Err(e) = pre_flush_tx.commit() { + drained_slot.set(Some(cs)); + return Err(WalletStorageError::Sqlite(e)); + } + } + + // Re-evaluate existence after the pre-flush: a buffered- + // only wallet now has rows on disk. + let exists_in_db = if exists_pre_flush { + true + } else { + conn.query_row( + "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |_| Ok(()), + ) + .optional()? + .is_some() + }; + + let backup_path = if skip_backup { + None + } else { + run_auto_backup( + &conn, + self.config.auto_backup_dir.as_deref(), + BackupKind::PreDelete { wallet_id }, + AutoBackupOperation::DeleteWallet, + )? + }; + + // Test-only hook: fires between the backup snapshot and + // the cascade EXCLUSIVE so TC-CODE-006-3 can simulate a + // cross-process peer that mutates `wallet_metadata` in + // the gap rusqlite's Backup API forces us to leave open. + #[cfg(any(test, feature = "__test-helpers"))] + self.consume_post_backup_hook(); + + // SQLite-native EXCLUSIVE for the cascade window. Excludes + // cross-process peers (other rusqlite Connections, sibling + // `SqlitePersister`s) that would otherwise commit rows for + // `wallet_id` between the backup snapshot and the cascade. + // The in-process mutex on `conn` alone never gave that + // guarantee. Peers waiting on the lock back off via + // SQLite's `busy_timeout`. + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; + + // Re-confirm existence post-EXCLUSIVE: a peer could have + // either inserted (raising the wallet from non-existent to + // existent) or deleted (vanishing it) between the backup + // and the lock acquisition. If a peer just deleted the + // wallet, the cascade is a no-op — we still commit because + // the operator's intent is satisfied. + let post_lock_exists = tx + .query_row( + "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |_| Ok(()), + ) + .optional()? + .is_some(); + if post_lock_exists != exists_in_db { + tracing::info!( + wallet_id = %hex::encode(wallet_id), + pre_lock_exists = exists_in_db, + post_lock_exists, + "wallet_metadata footprint changed across delete_wallet EXCLUSIVE acquisition" + ); + } + + let mut rows_removed_per_table = BTreeMap::new(); + for (table, scope) in PER_WALLET_TABLES { + // SQL injection note: `table` comes from a `&'static + // &'static str` constant compiled into the binary. There + // is no user input on this path. The SQL flavour + // (direct column vs. JOIN via `identities`) is picked + // by `count_rows_for_wallet_sql` per V002 schema. + let n: i64 = tx + .query_row( + &count_rows_for_wallet_sql(table, *scope), + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .optional()? + .unwrap_or(0); + rows_removed_per_table.insert(*table, usize::try_from(n).unwrap_or(usize::MAX)); + } + crate::sqlite::schema::wallet_meta::delete(&tx, &wallet_id)?; + tx.commit()?; + // Commit succeeded — drop the original drained changeset. + drop(drained_slot.take()); + // CMT-008: re-drain any changeset a Manual-mode store + // dropped into the buffer while we held conn. The wallet + // is gone — these writes are intentionally void. + if let Ok(Some(_late)) = self.buffer.take_for_flush(&wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + "discarded racing buffered changeset after delete_wallet commit" + ); + } + Ok(DeleteWalletReport { + wallet_id, + backup_path, + rows_removed_per_table, + }) + })(); + + if result.is_err() { + restore_buffer(&drained_slot); + } + result + } + + /// In Manual mode: attempt to flush every dirty wallet. In + /// Immediate mode: no-op (returns an empty report). + /// + /// Continues past per-wallet failures instead of fails-fast (N-1). + /// Each wallet's flush outcome lands on the returned + /// [`CommitReport`]: `succeeded` for durable writes, `failed` for + /// the classified `PersistenceError`. `still_pending` only fills + /// when a `LockPoisoned` short-circuit prevents the loop from + /// attempting the remaining wallets. + /// + /// Returns `Err` ONLY when even enumerating the dirty set fails + /// (e.g. the buffer mutex is poisoned). Once the loop starts, + /// every dirty wallet has a slot in the report. + pub fn commit_writes(&self) -> Result { + self.commit_writes_inner() + } + + fn commit_writes_inner(&self) -> Result { + let mut report = CommitReport { + succeeded: Vec::new(), + failed: Vec::new(), + still_pending: Vec::new(), + }; + if self.config.flush_mode == FlushMode::Immediate { + return Ok(report); + } + let dirty = self + .buffer + .dirty_wallets() + .map_err(PersistenceError::from)?; + let mut iter = dirty.into_iter(); + while let Some(id) = iter.next() { + match self.flush_inner(&id) { + Ok(()) => report.succeeded.push(id), + Err(PersistenceError::LockPoisoned) => { + // Mutex is gone — no point hammering the remaining + // wallets. Record this one as failed and shovel the + // rest into still_pending so the caller knows what + // was never attempted. + report.failed.push((id, PersistenceError::LockPoisoned)); + report.still_pending.extend(iter); + return Ok(report); + } + Err(e) => report.failed.push((id, e)), + } + } + Ok(report) + } + + /// `inspect` row-count summary. With `wallet_id = Some(id)`, scoped + /// to that wallet; otherwise total counts across all wallets. + pub fn inspect_counts( + &self, + wallet_id: Option<&WalletId>, + ) -> Result, WalletStorageError> { + let conn = self.conn()?; + let mut out = Vec::with_capacity(PER_WALLET_TABLES.len()); + for (table, scope) in PER_WALLET_TABLES { + // `table` is a compile-time constant — no SQL injection + // surface despite the `format!`. Per-wallet predicate uses + // `count_rows_for_wallet_sql` so V002 identity-scoped + // tables join through `identities`. + let n: i64 = match wallet_id { + Some(id) => conn + .query_row( + &count_rows_for_wallet_sql(table, *scope), + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .optional()? + .unwrap_or(0), + None => conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .optional()? + .unwrap_or(0), + }; + out.push((*table, usize::try_from(n).unwrap_or(usize::MAX))); + } + Ok(out) + } + + /// Lock the write connection. + pub(crate) fn conn(&self) -> Result, WalletStorageError> { + self.conn + .lock() + .map_err(|_| WalletStorageError::LockPoisoned) + } + + // The feature is named with Cargo's `__` prefix convention to + // signal "not part of the public API; downstream MUST NOT enable + // it" (https://doc.rust-lang.org/cargo/reference/features.html). + // The methods themselves are `#[doc(hidden)]` so they don't show + // up on docs.rs even when the feature is on. + /// Test-only: borrow the write connection. + /// + /// Tests use this to seed `wallet_metadata` rows directly, run + /// SELECTs against tables that aren't part of the public surface, + /// or probe `PRAGMA foreign_keys` / `PRAGMA journal_mode`. Gated + /// behind `cfg(test)` and the `__test-helpers` feature — + /// downstream crates MUST NOT enable it. + #[doc(hidden)] + #[cfg(any(test, feature = "__test-helpers"))] + pub fn lock_conn_for_test(&self) -> MutexGuard<'_, Connection> { + self.conn.lock().expect("conn mutex poisoned") + } + + /// Test-only: read the resolved config. Same visibility rules as + /// [`lock_conn_for_test`](Self::lock_conn_for_test). + #[doc(hidden)] + #[cfg(any(test, feature = "__test-helpers"))] + pub fn config_for_test(&self) -> &SqlitePersisterConfig { + &self.config + } + + fn flush_inner(&self, wallet_id: &WalletId) -> Result<(), PersistenceError> { + let cs = self + .buffer + .take_for_flush(wallet_id) + .map_err(PersistenceError::from)?; + let Some(cs) = cs else { return Ok(()) }; + + // Test-only injector: surface a primed failure without ever + // touching SQL so take/restore semantics are exercised end-to-end. + #[cfg(any(test, feature = "__test-helpers"))] + if let Some(injected) = self.consume_primed_flush_error() { + return self.handle_flush_error(wallet_id, cs, injected); + } + + match self.write_changeset_in_one_tx(wallet_id, &cs) { + Ok(()) => Ok(()), + Err(e) => self.handle_flush_error(wallet_id, cs, e), + } + } + + /// Apply every populated sub-changeset under one transaction and + /// commit. Returned `Err` is the per-area / commit failure verbatim + /// — classification + buffer restore happen one level up. + fn write_changeset_in_one_tx( + &self, + wallet_id: &WalletId, + cs: &PlatformWalletChangeSet, + ) -> Result<(), WalletStorageError> { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + apply_changeset_to_tx(&tx, wallet_id, cs)?; + tx.commit()?; + Ok(()) + } + + /// Classify the failure: transient errors restore the buffer and + /// surface as `FlushRetryable`; everything else drops the + /// changeset and returns the original variant. + // + // TODO(qa): TC-P2-008 — the fatal branch below covers + // `LockPoisoned`, but no end-to-end mutex-poison test exists. The + // spec deferred it as race-prone (a panicking thread plus a join + // is hard to reproduce deterministically); manually verified via + // `Mutex::lock` failure injection at the typed-error layer + // (`tc_p2_005_is_transient_table::lock_poisoned`). Anyone touching + // the classification policy or this branch must reconfirm by hand. + fn handle_flush_error( + &self, + wallet_id: &WalletId, + cs: PlatformWalletChangeSet, + err: WalletStorageError, + ) -> Result<(), PersistenceError> { + let field_count = populated_field_count(&cs); + let kind = err.error_kind_str(); + if err.is_transient() { + // A failed restore (e.g. poisoned buffer mutex) means the + // buffered changeset is gone — that is itself fatal and + // must surface, not be masked by the transient signal. + if let Err(restore_err) = self.buffer.restore(*wallet_id, cs) { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error_kind = restore_err.error_kind_str(), + restored_field_count = field_count, + "buffer restore failed after transient flush error — changeset lost" + ); + return Err(PersistenceError::from(restore_err)); + } + // Narrow the error to its rusqlite source per D-9 — only + // `Sqlite(SqliteFailure(BUSY|LOCKED, _))` qualifies for + // surfacing as `FlushRetryable`. + let source = match err { + WalletStorageError::Sqlite(rusq) => rusq, + WalletStorageError::FlushRetryable { source, .. } => source, + other => { + // Defensive: classifier said "transient" but source + // isn't rusqlite. Surface unwrapped — better than + // lying about the source type. + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error_kind = kind, + restored_field_count = field_count, + "transient classification with non-sqlite source — propagating raw" + ); + return Err(PersistenceError::from(other)); + } + }; + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error_kind = kind, + restored_field_count = field_count, + "flush failed transiently — buffer restored for retry" + ); + Err(PersistenceError::from(WalletStorageError::FlushRetryable { + wallet_id: *wallet_id, + source, + })) + } else { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error_kind = kind, + dropped_field_count = field_count, + "flush failed fatally — buffer wiped" + ); + // `cs` dropped here. + drop(cs); + Err(PersistenceError::from(err)) + } + } + + /// Test-only: arm a one-shot injection consumed by the next + /// `flush_inner`. Higher-level than `FailingConnection`; useful + /// when the test doesn't care which SQL error fires, only how the + /// wrapper reacts. + #[doc(hidden)] + #[cfg(any(test, feature = "__test-helpers"))] + pub fn force_next_flush_to_fail(&self, err: WalletStorageError) { + *self.primed_flush_error.lock().expect("primed_flush_error") = Some(err); + } + + #[cfg(any(test, feature = "__test-helpers"))] + fn consume_primed_flush_error(&self) -> Option { + self.primed_flush_error + .lock() + .expect("primed_flush_error") + .take() + } + + /// Test-only: arm a one-shot callback fired by `delete_wallet` + /// after the pre-delete backup snapshot completes and before the + /// cascade EXCLUSIVE tx begins. The callback is consumed (taken) + /// on first fire — subsequent deletes see the slot empty. + #[doc(hidden)] + #[cfg(any(test, feature = "__test-helpers"))] + pub fn arm_post_backup_hook(&self, hook: F) + where + F: FnOnce() + Send + 'static, + { + *self.post_backup_hook.lock().expect("post_backup_hook") = Some(Box::new(hook)); + } + + #[cfg(any(test, feature = "__test-helpers"))] + fn consume_post_backup_hook(&self) { + let hook = self + .post_backup_hook + .lock() + .expect("post_backup_hook") + .take(); + if let Some(hook) = hook { + hook(); + } + } + + /// Test-only: arm a one-shot pre-flush failure for the next + /// `delete_wallet` call. The injection fires only when there is + /// a drained buffered changeset to flush — i.e. when `delete_wallet` + /// actually exercises the pre-flush branch. + #[doc(hidden)] + #[cfg(any(test, feature = "__test-helpers"))] + pub fn force_next_pre_flush_to_fail(&self, err: WalletStorageError) { + *self + .primed_pre_flush_error + .lock() + .expect("primed_pre_flush_error") = Some(err); + } + + #[cfg(any(test, feature = "__test-helpers"))] + fn consume_primed_pre_flush_error(&self) -> Option { + self.primed_pre_flush_error + .lock() + .expect("primed_pre_flush_error") + .take() + } + + /// Test-only: probe whether the wallet has a buffered changeset. + /// Used by TC-CODE-006-2 to assert the buffer survives a failed + /// pre-flush without consuming it. + #[doc(hidden)] + #[cfg(any(test, feature = "__test-helpers"))] + pub fn buffer_has_changeset_for_test(&self, wallet_id: &WalletId) -> bool { + self.buffer + .dirty_wallets() + .map(|v| v.iter().any(|w| w == wallet_id)) + .unwrap_or(false) + } +} + +/// ATOM-007 (N-2): when a `Manual`-mode persister is dropped while +/// dirty wallets remain, log a structured `tracing::error!` so the +/// silent-data-loss footgun (the buffer dies with the persister) +/// surfaces in operator logs. +/// +/// We intentionally do NOT auto-flush from `Drop` — `flush_inner` +/// can fail and `Drop` cannot propagate errors, so a swallow there +/// would be a worse failure mode than the loud log. `Immediate`-mode +/// persisters are durable on every `store` so they never trip this. +impl Drop for SqlitePersister { + fn drop(&mut self) { + if self.config.flush_mode != FlushMode::Manual { + return; + } + // `dirty_wallets` only fails on a poisoned buffer mutex. A + // poisoned mutex on Drop already means the process is wedged; + // we still try to surface the lost state where we can. + let dirty = match self.buffer.dirty_wallets() { + Ok(d) => d, + Err(e) => { + tracing::error!( + target: "platform_wallet_storage", + error_kind = e.error_kind_str(), + "SqlitePersister dropped with buffer mutex poisoned — uncommitted state unrecoverable" + ); + return; + } + }; + if dirty.is_empty() { + return; + } + // `take_for_flush` mutates the buffer (drains the changeset). + // That is intentional here: the persister is being dropped, no + // future caller can observe the buffer, and `populated_field_count` + // needs to inspect the changeset to produce the diagnostic. Do + // NOT treat `impl Drop` as side-effect-free. + let total_fields: usize = dirty + .iter() + .filter_map(|id| { + self.buffer + .take_for_flush(id) + .ok() + .flatten() + .map(|cs| populated_field_count(&cs)) + }) + .sum(); + tracing::error!( + target: "platform_wallet_storage", + dirty_wallets = dirty.len(), + total_fields, + "SqlitePersister dropped with uncommitted Manual-mode writes" + ); + } +} + +impl PlatformWalletPersistence for SqlitePersister { + /// Merge `changeset` into the per-wallet buffer. + /// + /// N-7 / D-3 durability matrix: + /// - In [`FlushMode::Immediate`] the call is **durable on `Ok`** — + /// one SQLite transaction wraps every populated per-table apply, + /// so either all sub-changesets land or none do. A transient + /// failure restores the buffer and surfaces + /// [`WalletStorageError::FlushRetryable`] wrapped in + /// `PersistenceError::Backend`. + /// - In [`FlushMode::Manual`] the call only merges into the + /// in-memory buffer. Durability requires + /// [`flush`](Self::flush) (per-wallet) or + /// [`commit_writes`](Self::commit_writes) (every dirty wallet). + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.buffer + .store(wallet_id, changeset) + .map_err(PersistenceError::from)?; + match self.config.flush_mode { + FlushMode::Immediate => self.flush_inner(&wallet_id), + FlushMode::Manual => Ok(()), + } + } + + fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + self.flush_inner(&wallet_id) + } + + /// Load every wallet's start-state from disk. + /// + /// Populates `platform_addresses` per wallet. `wallets` stays empty + /// pending an upstream `key_wallet::Wallet::from_persisted` + /// constructor — the count of wallets that *would* be rehydrated is + /// surfaced as the structured field `wallets_pending_rehydration` + /// on the `tracing::info!` summary. + /// + /// Fail-hard: any row that fails to decode (or carries a malformed + /// `wallet_id`) aborts the whole load with a typed + /// [`WalletStorageError`]. Corruption is never silently skipped. + /// + /// **Query budget (FR-P4-6).** Constant-query w.r.t. wallet count: + /// one `SELECT` over `wallet_metadata` for the wallet-id list, then + /// per-wallet sync-header + count reads bounded by that list. + /// + /// # Concurrency (N-10) + /// + /// Holds the connection mutex for the duration of the read. + /// Concurrent `store` / `flush` / `delete_wallet` calls block + /// until `load` returns. Intended for one-shot use at process + /// startup, not interleaved with the hot write path. + /// + /// # Examples + /// + /// ```rust + /// use std::sync::Arc; + /// use platform_wallet::changeset::PlatformWalletPersistence; + /// use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + /// + /// # fn main() -> Result<(), platform_wallet_storage::WalletStorageError> { + /// // Per-test isolated path — no shared state, no real wallet data. + /// let dir = std::env::temp_dir().join(format!( + /// "platform-wallet-storage-doctest-{}-{}", + /// std::process::id(), + /// std::time::SystemTime::now() + /// .duration_since(std::time::UNIX_EPOCH) + /// .unwrap() + /// .as_nanos() + /// )); + /// std::fs::create_dir_all(&dir).unwrap(); + /// let db_path = dir.join("wallets.db"); + /// + /// let config = SqlitePersisterConfig::new(&db_path); + /// let persister: Arc = + /// Arc::new(SqlitePersister::open(config)?); + /// + /// // Empty database → empty start-state, no error. + /// let state = persister.load().expect("load"); + /// assert!(state.platform_addresses.is_empty()); + /// assert!(state.wallets.is_empty()); + /// + /// // Cleanup — the doctest owns the directory. + /// drop(persister); + /// let _ = std::fs::remove_dir_all(&dir); + /// # Ok(()) + /// # } + /// ``` + fn load(&self) -> Result { + let conn = self.conn().map_err(PersistenceError::from)?; + let mut state = ClientStartState::default(); + + let addrs_all = schema::platform_addrs::load_all(&conn).map_err(PersistenceError::from)?; + let wallets_seen = addrs_all.len(); + let mut addresses_loaded: usize = 0; + + for (wallet_id, (addrs, count)) in addrs_all { + if count > 0 + || addrs.sync_height > 0 + || addrs.sync_timestamp > 0 + || addrs.last_known_recent_block > 0 + { + addresses_loaded += count; + state.platform_addresses.insert(wallet_id, addrs); + } + } + + tracing::info!( + wallets_seen, + addresses_loaded, + wallets_rehydrated = 0usize, + wallets_pending_rehydration = wallets_seen, + unimplemented = ?LOAD_UNIMPLEMENTED, + "load() summary" + ); + Ok(state) + } + + fn get_core_tx_record( + &self, + wallet_id: WalletId, + txid: &dashcore::Txid, + ) -> Result< + Option, + PersistenceError, + > { + let conn = self.conn().map_err(PersistenceError::from)?; + schema::core_state::get_tx_record(&conn, &wallet_id, txid).map_err(PersistenceError::from) + } + + /// Trait-dispatch entry into the safe-by-default cascade delete. + /// Always takes an auto-backup (`auto_backup_dir` must be set, else + /// returns `WalletStorageError::AutoBackupDisabled` mapped into a + /// fatal `PersistenceError`). The inherent + /// [`SqlitePersister::delete_wallet_skip_backup`] stays available + /// for the CLI's `--no-auto-backup` flag and isn't reachable + /// through the trait by design. + fn delete_wallet(&self, wallet_id: WalletId) -> Result { + self.delete_wallet_inner(wallet_id, false) + .map_err(PersistenceError::from) + } + + fn commit_writes(&self) -> Result { + self.commit_writes_inner() + } +} + +// ----- Helpers ----- + +/// Count of top-level slots that carry any data. Feeds the persister's +/// `restored_field_count` / `dropped_field_count` tracing fields so +/// operators can see how much was kept or dropped on a flush retry / +/// fatal failure. Computed here from the public `PlatformWalletChangeSet` +/// fields + `Merge::is_empty()` so no storage-only helper leaks into +/// the `rs-platform-wallet` public API. +fn populated_field_count(cs: &PlatformWalletChangeSet) -> usize { + [ + cs.core.is_empty(), + cs.identities.is_empty(), + cs.identity_keys.is_empty(), + cs.contacts.is_empty(), + cs.platform_addresses.is_empty(), + cs.asset_locks.is_empty(), + cs.token_balances.is_empty(), + cs.dashpay_profiles.as_ref().is_none_or(|m| m.is_empty()), + cs.dashpay_payments_overlay + .as_ref() + .is_none_or(|m| m.is_empty()), + cs.wallet_metadata.is_none(), + cs.account_registrations.is_empty(), + cs.account_address_pools.is_empty(), + ] + .iter() + .filter(|empty| !**empty) + .count() +} + +fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageError> { + if config.synchronous == Synchronous::Off { + return Err(WalletStorageError::ConfigInvalid { + reason: "synchronous=Off is rejected (data-loss footgun)", + }); + } + // `journal_mode=Memory` keeps the rollback journal in RAM and + // `journal_mode=Off` disables it outright. Either turns crash- + // safety into a coin flip for a wallet DB — reject loudly instead + // of silently corrupting on the next power loss. + match config.journal_mode { + crate::sqlite::config::JournalMode::Memory => { + return Err(WalletStorageError::ConfigInvalid { + reason: "journal_mode=Memory is rejected (crash-unsafe)", + }); + } + crate::sqlite::config::JournalMode::Off => { + return Err(WalletStorageError::ConfigInvalid { + reason: "journal_mode=Off is rejected (crash-unsafe)", + }); + } + _ => {} + } + // `busy_timeout=0` makes contended writers fail-fast with BUSY + // instead of waiting — non-fatal, but the operator almost certainly + // didn't mean it. Warn rather than reject because a few tests + // legitimately want the fail-fast behaviour. + if config.busy_timeout.is_zero() { + tracing::warn!( + "SqlitePersisterConfig.busy_timeout=0; contended writers will return BUSY \ + instead of waiting — set a non-zero timeout (default 5s) unless this is intentional" + ); + } + Ok(()) +} + +fn apply_pragmas( + conn: &mut Connection, + config: &SqlitePersisterConfig, +) -> Result<(), WalletStorageError> { + // `foreign_keys` is enabled + read-back-asserted in + // `crate::sqlite::conn::open_conn`, the single open choke-point. + conn.pragma_update(None, "journal_mode", config.journal_mode.pragma_value())?; + conn.pragma_update(None, "synchronous", config.synchronous.pragma_value())?; + let ms = safe_cast::u64_to_i64( + "busy_timeout_ms", + u64::try_from(config.busy_timeout.as_millis()).unwrap_or(i64::MAX as u64), + )?; + conn.pragma_update(None, "busy_timeout", ms)?; + Ok(()) +} + +/// Apply every populated sub-changeset of `cs` against the supplied +/// SQLite transaction. Does not commit; the caller owns the tx +/// lifecycle. Splitting this out from `write_changeset_in_one_tx` +/// lets `delete_wallet_inner` flush a drained buffer into a bespoke +/// pre-delete tx (CODE-006) without re-opening the connection. +fn apply_changeset_to_tx( + tx: &rusqlite::Transaction<'_>, + wallet_id: &WalletId, + cs: &PlatformWalletChangeSet, +) -> Result<(), WalletStorageError> { + if let Some(meta) = cs.wallet_metadata.as_ref() { + schema::wallet_meta::upsert(tx, wallet_id, meta)?; + } + if !cs.account_registrations.is_empty() { + schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?; + } + if !cs.account_address_pools.is_empty() { + schema::accounts::apply_pools(tx, wallet_id, &cs.account_address_pools)?; + } + if let Some(core) = cs.core.as_ref() { + schema::core_state::apply(tx, wallet_id, core)?; + } + if let Some(identities) = cs.identities.as_ref() { + schema::identities::apply(tx, wallet_id, identities)?; + } + if let Some(keys) = cs.identity_keys.as_ref() { + schema::identity_keys::apply(tx, wallet_id, keys)?; + } + if let Some(contacts) = cs.contacts.as_ref() { + schema::contacts::apply(tx, wallet_id, contacts)?; + } + if let Some(addrs) = cs.platform_addresses.as_ref() { + schema::platform_addrs::apply(tx, wallet_id, addrs)?; + } + if let Some(locks) = cs.asset_locks.as_ref() { + schema::asset_locks::apply(tx, wallet_id, locks)?; + } + if let Some(balances) = cs.token_balances.as_ref() { + schema::token_balances::apply(tx, wallet_id, balances)?; + } + if cs.dashpay_profiles.is_some() || cs.dashpay_payments_overlay.is_some() { + schema::dashpay::apply( + tx, + wallet_id, + cs.dashpay_profiles.as_ref(), + cs.dashpay_payments_overlay.as_ref(), + )?; + } + Ok(()) +} + +/// Take a single auto-backup. Shared code path for open-time +/// (pre-migration), pre-restore, and pre-delete invocations. Returns +/// the absolute path written, or [`WalletStorageError::AutoBackupDisabled`] +/// when `auto_backup_dir` is `None`. +pub(crate) fn run_auto_backup( + src_conn: &Connection, + auto_backup_dir: Option<&Path>, + kind: BackupKind, + operation: AutoBackupOperation, +) -> Result, WalletStorageError> { + let Some(dir) = auto_backup_dir else { + return Err(WalletStorageError::AutoBackupDisabled { operation }); + }; + ensure_dir(dir)?; + let dest = dir.join(backup::auto_backup_filename(kind)); + backup::run_to(src_conn, &dest)?; + Ok(Some(dest)) +} + +fn ensure_dir(dir: &Path) -> Result<(), WalletStorageError> { + if !dir.exists() { + std::fs::create_dir_all(dir).map_err(|source| { + WalletStorageError::AutoBackupDirUnwritable { + dir: dir.to_path_buf(), + source, + } + })?; + } + // ATOM-014 (A-7): best-effort writability probe via `NamedTempFile` + // (unguessable name, no race against concurrent persister opens — + // CODE-008). This is TOCTOU by construction — the dir CAN flip to + // unwritable between the probe and `backup::run_to` below — but + // the real write below has its own error path, so the worst case + // is the operator gets the typed error from the actual backup + // attempt instead of this fast-fail probe. + match tempfile::NamedTempFile::new_in(dir) { + Ok(_probe) => Ok(()), + Err(source) => Err(WalletStorageError::AutoBackupDirUnwritable { + dir: dir.to_path_buf(), + source, + }), + } +} + +fn count_pending( + conn: &mut Connection, + embedded: &[(i32, String)], +) -> Result { + if !crate::sqlite::migrations::has_schema_history(conn)? { + return Ok(embedded.len()); + } + let applied: std::collections::HashSet = { + let mut stmt = conn.prepare("SELECT version FROM refinery_schema_history")?; + let rows: Result, _> = + stmt.query_map([], |row| row.get::<_, i64>(0))?.collect(); + rows? + }; + Ok(embedded + .iter() + .filter(|(v, _)| !applied.contains(&(*v as i64))) + .count()) +} + +fn current_schema_version(conn: &Connection) -> Result, WalletStorageError> { + let row = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get::<_, Option>(0), + ) + .optional()? + .flatten(); + Ok(row.map(|v| v as i32)) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs new file mode 100644 index 00000000000..8e05a109802 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -0,0 +1,144 @@ +//! `account_registrations` + `account_address_pools` writers. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::{AccountAddressPoolEntry, AccountRegistrationEntry}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +pub fn apply_registrations( + tx: &Transaction<'_>, + wallet_id: &WalletId, + entries: &[AccountRegistrationEntry], +) -> Result<(), WalletStorageError> { + if entries.is_empty() { + return Ok(()); + } + // `account_xpub_bytes` carries the bincode-serde encoded + // `AccountRegistrationEntry` (xpub + account_type). The + // separate `account_type` / `account_index` columns mirror + // the entry for direct SQL lookups. + let mut stmt = tx.prepare_cached( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id, account_type, account_index) DO UPDATE SET \ + account_xpub_bytes = excluded.account_xpub_bytes", + )?; + for entry in entries { + let account_type = account_type_db_label(&entry.account_type); + let account_index = account_index(&entry.account_type); + let payload = blob::encode(entry)?; + stmt.execute(params![ + wallet_id.as_slice(), + account_type, + i64::from(account_index), + payload, + ])?; + } + Ok(()) +} + +pub fn apply_pools( + tx: &Transaction<'_>, + wallet_id: &WalletId, + entries: &[AccountAddressPoolEntry], +) -> Result<(), WalletStorageError> { + if entries.is_empty() { + return Ok(()); + } + let mut stmt = tx.prepare_cached( + "INSERT INTO account_address_pools \ + (wallet_id, account_type, account_index, pool_type, snapshot_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5) \ + ON CONFLICT(wallet_id, account_type, account_index, pool_type) DO UPDATE SET \ + snapshot_blob = excluded.snapshot_blob", + )?; + for entry in entries { + let account_type = account_type_db_label(&entry.account_type); + let account_index = account_index(&entry.account_type); + let pool_type = pool_type_db_label(&entry.pool_type); + let payload = blob::encode(entry)?; + stmt.execute(params![ + wallet_id.as_slice(), + account_type, + i64::from(account_index), + pool_type, + payload, + ])?; + } + Ok(()) +} + +/// Stable database label for an `AccountType` variant. +/// +/// Used for the `account_type` text column on `account_registrations`, +/// `account_address_pools`, and `core_derived_addresses`. The +/// `Debug` impl on `AccountType` is NOT a stable serialisation +/// format; this match is the contract. Variants identical in +/// label are distinguished by the companion `account_index` column. +/// +/// Adding a variant to upstream `AccountType` makes this match +/// exhaustive-check fail at compile time, forcing an explicit label +/// decision rather than silent garbage. +pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &'static str { + use key_wallet::account::AccountType; + match at { + AccountType::Standard { .. } => "standard", + AccountType::CoinJoin { .. } => "coinjoin", + AccountType::IdentityRegistration => "identity_registration", + AccountType::IdentityTopUp { .. } => "identity_topup", + AccountType::IdentityTopUpNotBoundToIdentity => "identity_topup_unbound", + AccountType::IdentityInvitation => "identity_invitation", + AccountType::AssetLockAddressTopUp => "asset_lock_address_topup", + AccountType::AssetLockShieldedAddressTopUp => "asset_lock_shielded_topup", + AccountType::ProviderVotingKeys => "provider_voting", + AccountType::ProviderOwnerKeys => "provider_owner", + AccountType::ProviderOperatorKeys => "provider_operator", + AccountType::ProviderPlatformKeys => "provider_platform", + AccountType::DashpayReceivingFunds { .. } => "dashpay_receiving", + AccountType::DashpayExternalAccount { .. } => "dashpay_external", + AccountType::PlatformPayment { .. } => "platform_payment", + } +} + +/// Stable database label for an `AddressPoolType` variant. +pub(crate) fn pool_type_db_label( + pool: &key_wallet::managed_account::address_pool::AddressPoolType, +) -> &'static str { + use key_wallet::managed_account::address_pool::AddressPoolType; + match pool { + AddressPoolType::External => "external", + AddressPoolType::Internal => "internal", + AddressPoolType::Absent => "absent", + AddressPoolType::AbsentHardened => "absent_hardened", + } +} + +/// Numeric account index embedded in an `AccountType`. +/// +/// Persisted in the `account_index` column of `account_registrations`, +/// `account_address_pools`, and `core_derived_addresses` (the last of +/// which is the script→account lookup the UTXO writer joins against). +pub(crate) fn account_index(at: &key_wallet::account::AccountType) -> u32 { + use key_wallet::account::AccountType; + match at { + AccountType::Standard { index, .. } => *index, + AccountType::CoinJoin { index } => *index, + AccountType::IdentityRegistration => 0, + AccountType::IdentityTopUp { registration_index } => *registration_index, + AccountType::IdentityTopUpNotBoundToIdentity => 0, + AccountType::IdentityInvitation => 0, + AccountType::AssetLockAddressTopUp => 0, + AccountType::AssetLockShieldedAddressTopUp => 0, + AccountType::ProviderVotingKeys => 0, + AccountType::ProviderOwnerKeys => 0, + AccountType::ProviderOperatorKeys => 0, + AccountType::ProviderPlatformKeys => 0, + AccountType::DashpayReceivingFunds { index, .. } => *index, + AccountType::DashpayExternalAccount { index, .. } => *index, + AccountType::PlatformPayment { account, .. } => *account, + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs new file mode 100644 index 00000000000..5847b514607 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -0,0 +1,153 @@ +//! `asset_locks` table writer + reader. +//! +//! Each row stores the lifecycle status as a string column for direct +//! SQL queries, plus a bincode-serde encoded `AssetLockEntry` in the +//! `lifecycle_blob` column. + +use std::collections::BTreeMap; + +use dashcore::OutPoint; +use rusqlite::{params, Connection, Transaction}; + +use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; +use platform_wallet::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &AssetLockChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.asset_locks.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO asset_locks \ + (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \ + status = excluded.status, \ + account_index = excluded.account_index, \ + identity_index = excluded.identity_index, \ + amount_duffs = excluded.amount_duffs, \ + lifecycle_blob = excluded.lifecycle_blob", + )?; + for (op, entry) in &cs.asset_locks { + let op_bytes = blob::encode_outpoint(op); + let lifecycle_blob = blob::encode(entry)?; + stmt.execute(params![ + wallet_id.as_slice(), + &op_bytes[..], + status_str(&entry.status), + i64::from(entry.account_index), + i64::from(entry.identity_index), + crate::sqlite::util::safe_cast::u64_to_i64( + "asset_locks.amount_duffs", + entry.amount_duffs, + )?, + lifecycle_blob, + ])?; + } + } + if !cs.removed.is_empty() { + let mut stmt = + tx.prepare_cached("DELETE FROM asset_locks WHERE wallet_id = ?1 AND outpoint = ?2")?; + for op in &cs.removed { + let op_bytes = blob::encode_outpoint(op); + stmt.execute(params![wallet_id.as_slice(), &op_bytes[..]])?; + } + } + Ok(()) +} + +fn status_str(s: &AssetLockStatus) -> &'static str { + match s { + AssetLockStatus::Built => "built", + AssetLockStatus::Broadcast => "broadcast", + AssetLockStatus::InstantSendLocked => "is_locked", + AssetLockStatus::ChainLocked => "chain_locked", + AssetLockStatus::Consumed => "consumed", + } +} + +/// Per-wallet asset-lock slice as returned by the readers — outer-keyed +/// by `account_index`, inner-keyed by outpoint. +pub type AssetLocksByAccount = BTreeMap>; + +/// Decode one raw `(outpoint_bytes, account_index, lifecycle_blob)` +/// tuple into the typed `(account_index, OutPoint, TrackedAssetLock)` +/// triple that [`load_state`] consumes. +/// +/// Hard-fail behaviour: a malformed outpoint, blob, or out-of-range +/// account index returns a typed [`WalletStorageError`]. Every caller +/// propagates that error — corruption is never silently skipped. +fn decode_row( + op_bytes: &[u8], + account_index: i64, + blob_bytes: &[u8], +) -> Result<(u32, OutPoint, TrackedAssetLock), WalletStorageError> { + let outpoint = blob::decode_outpoint(op_bytes)?; + let entry: AssetLockEntry = blob::decode(blob_bytes)?; + let account_index = + u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { + field: "asset_locks.account_index", + value: account_index as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + // CMT-007: typed-column vs blob cross-check, symmetric with + // IdentityKeyEntryMismatch. A torn write / partial migration / + // restored corruption that passes PRAGMA integrity_check would + // otherwise silently mis-bucket the lock into the wrong account or + // report a different outpoint than the indexed column it was + // selected by. + if entry.out_point != outpoint || entry.account_index != account_index { + return Err(WalletStorageError::AssetLockEntryMismatch { + typed_outpoint: outpoint.to_string(), + blob_outpoint: entry.out_point.to_string(), + typed_account_index: account_index, + blob_account_index: entry.account_index, + }); + } + let tracked = TrackedAssetLock { + out_point: entry.out_point, + transaction: entry.transaction, + account_index: entry.account_index, + funding_type: entry.funding_type, + identity_index: entry.identity_index, + amount: entry.amount_duffs, + status: entry.status, + proof: entry.proof, + }; + Ok((account_index, outpoint, tracked)) +} + +/// Build the per-wallet asset-lock slice for `ClientStartState` from +/// the `asset_locks` table, bucketed by account index. Every status +/// variant the changeset writes is considered "active": consumed +/// locks leave the table via [`AssetLockChangeSet::removed`], so a +/// row present here is by definition still in play. Any row that +/// fails to read or decode is a hard error — corruption is never +/// silently dropped. +pub fn load_state( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let mut stmt = conn.prepare( + "SELECT outpoint, account_index, lifecycle_blob \ + FROM asset_locks WHERE wallet_id = ?1", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let op_bytes: Vec = row.get(0)?; + let account_index: i64 = row.get(1)?; + let blob_bytes: Vec = row.get(2)?; + Ok((op_bytes, account_index, blob_bytes)) + })?; + let mut out: AssetLocksByAccount = BTreeMap::new(); + for r in rows { + let (op_bytes, account_index, blob_bytes) = r?; + let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes)?; + out.entry(acct).or_default().insert(outpoint, tracked); + } + Ok(out) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs new file mode 100644 index 00000000000..7d42887c56b --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs @@ -0,0 +1,159 @@ +//! BLOB-column codec helpers. +//! +//! Thin error-mapping wrappers around `bincode::serde` so every +//! `_blob` column in the SQLite schema uses one encoding path. Schema +//! evolution is gated by the refinery migration version on the +//! database as a whole — there is no per-blob revision tag. +//! +//! [`encode_outpoint`] / [`decode_outpoint`] are a separate concern: +//! outpoints serve as primary-key fragments in typed columns, not as +//! blob payloads, and need a fixed on-disk layout for indexed lookups. + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::sqlite::error::WalletStorageError; + +/// Hard cap on bincode-serde decode allocations. 16 MiB is two orders +/// of magnitude above any legitimate per-row payload we ship — a +/// hostile or corrupted backup with an inflated length prefix is +/// rejected before the allocator wakes up. Applied symmetrically to +/// encode + decode so we can't write a payload we'd then refuse. +pub const BLOB_SIZE_LIMIT_BYTES: usize = 16 * 1024 * 1024; + +fn bounded_config() -> bincode::config::Configuration< + bincode::config::LittleEndian, + bincode::config::Varint, + bincode::config::Limit, +> { + bincode::config::standard().with_limit::() +} + +/// Encode a serde-derived value into a `BLOB` payload. +pub fn encode(value: &T) -> Result, WalletStorageError> { + Ok(bincode::serde::encode_to_vec(value, bounded_config())?) +} + +/// Decode a `BLOB` payload back into a serde-derived value. Rejects +/// trailing bytes so a corrupt or forward-incompatible payload fails +/// loudly instead of decoding a stale prefix — mirroring the strict +/// length check in [`decode_outpoint`]. Also caps in-decode +/// allocations at [`BLOB_SIZE_LIMIT_BYTES`] so a crafted length +/// prefix can't OOM the host (CMT-006). +pub fn decode(blob: &[u8]) -> Result { + if blob.len() > BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: blob.len(), + limit_bytes: BLOB_SIZE_LIMIT_BYTES, + }); + } + let (value, consumed) = match bincode::serde::decode_from_slice(blob, bounded_config()) { + Ok(v) => v, + Err(bincode::error::DecodeError::LimitExceeded) => { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: blob.len(), + limit_bytes: BLOB_SIZE_LIMIT_BYTES, + }); + } + Err(other) => return Err(WalletStorageError::from(other)), + }; + if consumed != blob.len() { + return Err(WalletStorageError::blob_decode( + "unexpected trailing bytes in blob payload", + )); + } + Ok(value) +} + +/// Encode a `dashcore::OutPoint` (txid + vout) as 36 bytes. +pub fn encode_outpoint(op: &dashcore::OutPoint) -> [u8; 36] { + let mut out = [0u8; 36]; + out[..32].copy_from_slice(op.txid.as_ref()); + out[32..].copy_from_slice(&op.vout.to_le_bytes()); + out +} + +/// Decode a 36-byte outpoint. +pub fn decode_outpoint(bytes: &[u8]) -> Result { + use dashcore::hashes::Hash; + if bytes.len() != 36 { + return Err(WalletStorageError::blob_decode( + "outpoint must be exactly 36 bytes", + )); + } + let txid = dashcore::Txid::from_slice(&bytes[..32])?; + let mut vout_bytes = [0u8; 4]; + vout_bytes.copy_from_slice(&bytes[32..]); + Ok(dashcore::OutPoint { + txid, + vout: u32::from_le_bytes(vout_bytes), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] + struct Dummy { + a: u32, + b: String, + } + + #[test] + fn encode_decode_roundtrip() { + let value = Dummy { + a: 42, + b: "hello".into(), + }; + let blob = encode(&value).unwrap(); + let decoded: Dummy = decode(&blob).unwrap(); + assert_eq!(decoded, value); + } + + #[test] + fn decode_rejects_trailing_bytes() { + let value = Dummy { + a: 7, + b: "world".into(), + }; + let mut blob = encode(&value).unwrap(); + blob.push(0x00); + let res: Result = decode(&blob); + assert!( + matches!(res, Err(WalletStorageError::BlobDecode { .. })), + "expected BlobDecode on trailing bytes, got {res:?}" + ); + } + + /// CMT-006: a blob larger than the per-row cap is rejected with a + /// typed `BlobTooLarge`, not generic `BlobDecode` and not an OOM. + /// We synthesize the oversize payload directly (the in-band limit + /// would prevent encoding it through the helper). + #[test] + fn decode_rejects_oversize_blob_with_blob_too_large() { + let oversize = vec![0u8; BLOB_SIZE_LIMIT_BYTES + 1]; + let res: Result, _> = decode(&oversize); + match res { + Err(WalletStorageError::BlobTooLarge { + len_bytes, + limit_bytes, + }) => { + assert_eq!(len_bytes, BLOB_SIZE_LIMIT_BYTES + 1); + assert_eq!(limit_bytes, BLOB_SIZE_LIMIT_BYTES); + } + other => panic!("expected BlobTooLarge, got {other:?}"), + } + } + + #[test] + fn outpoint_roundtrip() { + use dashcore::hashes::Hash; + let op = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([7u8; 32]), + vout: 9, + }; + let bytes = encode_outpoint(&op); + assert_eq!(decode_outpoint(&bytes).unwrap(), op); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs new file mode 100644 index 00000000000..8fbee8651da --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -0,0 +1,211 @@ +//! `contacts_sent` / `contacts_recv` / `contacts_established` writers +//! and per-wallet reader. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::ContactChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +#[cfg(feature = "__test-helpers")] +use dpp::prelude::Identifier; +#[cfg(feature = "__test-helpers")] +use platform_wallet::changeset::{ + ContactRequestEntry, ReceivedContactRequestKey, SentContactRequestKey, +}; +#[cfg(feature = "__test-helpers")] +use platform_wallet::wallet::identity::EstablishedContact; +#[cfg(feature = "__test-helpers")] +use rusqlite::Connection; +#[cfg(feature = "__test-helpers")] +use std::collections::BTreeMap; + +/// Storage-internal snapshot of one wallet's `contacts_*` rows. +/// +/// Mirrors the populated-only subset of +/// [`ContactChangeSet`](platform_wallet::changeset::ContactChangeSet); +/// `removed_*` are absent because deletes never reach storage as rows +/// (the writer applies them as `DELETE`s). Only built by the +/// `__test-helpers` reader path so this crate's own integration tests +/// can assert on the hardened (fail-hard) contacts reader; the +/// production `load()` reconstruction that consumes it lands with the +/// rehydration feature. +#[derive(Debug, Default, PartialEq)] +#[cfg(feature = "__test-helpers")] +pub struct ContactsRecords { + pub sent_requests: BTreeMap, + pub incoming_requests: BTreeMap, + pub established: BTreeMap, +} + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &ContactChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.sent_requests.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO contacts_sent (wallet_id, owner_id, recipient_id, entry_blob) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id, owner_id, recipient_id) DO UPDATE SET entry_blob = excluded.entry_blob", + )?; + for (key, entry) in &cs.sent_requests { + let payload = blob::encode(entry)?; + stmt.execute(params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.recipient_id.as_slice(), + payload, + ])?; + } + } + if !cs.removed_sent.is_empty() { + let mut stmt = tx.prepare_cached( + "DELETE FROM contacts_sent WHERE wallet_id = ?1 AND owner_id = ?2 AND recipient_id = ?3", + )?; + for key in &cs.removed_sent { + stmt.execute(params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.recipient_id.as_slice(), + ])?; + } + } + if !cs.incoming_requests.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO contacts_recv (wallet_id, owner_id, sender_id, entry_blob) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id, owner_id, sender_id) DO UPDATE SET entry_blob = excluded.entry_blob", + )?; + for (key, entry) in &cs.incoming_requests { + let payload = blob::encode(entry)?; + stmt.execute(params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.sender_id.as_slice(), + payload, + ])?; + } + } + if !cs.removed_incoming.is_empty() { + let mut stmt = tx.prepare_cached( + "DELETE FROM contacts_recv WHERE wallet_id = ?1 AND owner_id = ?2 AND sender_id = ?3", + )?; + for key in &cs.removed_incoming { + stmt.execute(params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.sender_id.as_slice(), + ])?; + } + } + if !cs.established.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO contacts_established (wallet_id, owner_id, contact_id, entry_blob) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id, owner_id, contact_id) DO UPDATE SET entry_blob = excluded.entry_blob", + )?; + for (key, established) in &cs.established { + let payload = blob::encode(established)?; + stmt.execute(params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.recipient_id.as_slice(), + payload, + ])?; + } + } + Ok(()) +} + +/// Build a [`ContactsRecords`] for one wallet from the three +/// `contacts_*` tables. Any row that fails to decode is a hard error — +/// corruption is never silently dropped. +#[cfg(feature = "__test-helpers")] +pub(crate) fn load_state( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let mut state = ContactsRecords::default(); + + let mut sent_stmt = conn.prepare( + "SELECT owner_id, recipient_id, entry_blob FROM contacts_sent WHERE wallet_id = ?1", + )?; + let mut rows = sent_stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let owner: Vec = row.get(0)?; + let recipient: Vec = row.get(1)?; + let payload: Vec = row.get(2)?; + let (owner_id, recipient_id) = decode_pair_key(&owner, &recipient)?; + let entry: ContactRequestEntry = blob::decode(&payload)?; + state.sent_requests.insert( + SentContactRequestKey { + owner_id, + recipient_id, + }, + entry, + ); + } + + let mut recv_stmt = conn.prepare( + "SELECT owner_id, sender_id, entry_blob FROM contacts_recv WHERE wallet_id = ?1", + )?; + let mut rows = recv_stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let owner: Vec = row.get(0)?; + let sender: Vec = row.get(1)?; + let payload: Vec = row.get(2)?; + let (owner_id, sender_id) = decode_pair_key(&owner, &sender)?; + let entry: ContactRequestEntry = blob::decode(&payload)?; + state.incoming_requests.insert( + ReceivedContactRequestKey { + owner_id, + sender_id, + }, + entry, + ); + } + + let mut est_stmt = conn.prepare( + "SELECT owner_id, contact_id, entry_blob FROM contacts_established WHERE wallet_id = ?1", + )?; + let mut rows = est_stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let owner: Vec = row.get(0)?; + let contact: Vec = row.get(1)?; + let payload: Vec = row.get(2)?; + let (owner_id, recipient_id) = decode_pair_key(&owner, &contact)?; + let value: EstablishedContact = blob::decode(&payload)?; + state.established.insert( + SentContactRequestKey { + owner_id, + recipient_id, + }, + value, + ); + } + + Ok(state) +} + +#[cfg(feature = "__test-helpers")] +fn decode_pair_key(a: &[u8], b: &[u8]) -> Result<(Identifier, Identifier), WalletStorageError> { + let a32 = <[u8; 32]>::try_from(a) + .map_err(|_| WalletStorageError::blob_decode("contacts.id column is not 32 bytes"))?; + let b32 = <[u8; 32]>::try_from(b) + .map_err(|_| WalletStorageError::blob_decode("contacts.id column is not 32 bytes"))?; + Ok((Identifier::from(a32), Identifier::from(b32))) +} + +/// Test-helper wrapper over [`load_state`] so this crate's integration +/// tests can assert on the hardened (fail-hard) contacts reader without +/// promoting the production surface beyond `pub(crate)`. +#[cfg(feature = "__test-helpers")] +pub fn load_state_for_test( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + load_state(conn, wallet_id) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs new file mode 100644 index 00000000000..02bda187282 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -0,0 +1,320 @@ +//! Writers + readers for the `core_*` tables. + +use std::collections::BTreeMap; + +use rusqlite::{params, Connection, OptionalExtension, Transaction}; + +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::Utxo; +use platform_wallet::changeset::CoreChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +/// Apply a `CoreChangeSet` inside a transaction. +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &CoreChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.records.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(wallet_id, txid) DO UPDATE SET \ + height = excluded.height, \ + block_hash = excluded.block_hash, \ + block_time = excluded.block_time, \ + finalized = excluded.finalized, \ + record_blob = excluded.record_blob", + )?; + for record in &cs.records { + let block_info = record.block_info(); + let height = block_info.map(|b| i64::from(b.height())); + let block_hash = block_info.map(|b| AsRef::<[u8]>::as_ref(&b.block_hash()).to_vec()); + let block_time = block_info.map(|b| i64::from(b.timestamp())); + let finalized = block_info.is_some(); + let payload = blob::encode(record)?; + stmt.execute(params![ + wallet_id.as_slice(), + AsRef::<[u8]>::as_ref(&record.txid), + height, + block_hash, + block_time, + finalized, + payload, + ])?; + } + } + // Derived addresses are written BEFORE UTXOs (within the same + // transaction) so the UTXO writer's address→account_index lookup + // sees the freshly recorded rows. + if !cs.addresses_derived.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, address, derivation_path, used) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(wallet_id, account_type, address) DO UPDATE SET \ + account_index = excluded.account_index, \ + derivation_path = excluded.derivation_path", + )?; + for da in &cs.addresses_derived { + let account_type = + crate::sqlite::schema::accounts::account_type_db_label(&da.account_type); + let account_index = crate::sqlite::schema::accounts::account_index(&da.account_type); + let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&da.pool_type); + let address = da.address.to_string(); + let path = format!("{}/{}", pool_type, da.derivation_index); + stmt.execute(params![ + wallet_id.as_slice(), + account_type, + i64::from(account_index), + address, + path, + false + ])?; + } + } + if !cs.new_utxos.is_empty() { + let mut stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; + let mut lookup_stmt = tx.prepare_cached(ACCOUNT_INDEX_BY_ADDRESS_SQL)?; + for utxo in &cs.new_utxos { + execute_upsert_utxo(&mut stmt, &mut lookup_stmt, wallet_id, utxo, false)?; + } + } + if !cs.spent_utxos.is_empty() { + let mut exists_stmt = + tx.prepare_cached("SELECT 1 FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2")?; + let mut mark_spent_stmt = tx.prepare_cached( + "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + let mut upsert_stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; + let mut lookup_stmt = tx.prepare_cached(ACCOUNT_INDEX_BY_ADDRESS_SQL)?; + for utxo in &cs.spent_utxos { + let op = blob::encode_outpoint(&utxo.outpoint); + let exists: bool = exists_stmt + .query_row(params![wallet_id.as_slice(), &op[..]], |_| Ok(true)) + .optional()? + .unwrap_or(false); + if exists { + mark_spent_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; + } else { + // Spent-only synthetic row: best-effort account_index + // from the derived-address map. A spend of an + // externally-funded address we never derived defaults + // to 0 (logged) — harmless, since spent rows are + // excluded from `list_unspent_utxos`. + execute_upsert_utxo(&mut upsert_stmt, &mut lookup_stmt, wallet_id, utxo, true)?; + } + } + } + if !cs.instant_locks_for_non_final_records.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(wallet_id, txid) DO UPDATE SET islock_blob = excluded.islock_blob", + )?; + for (txid, islock) in &cs.instant_locks_for_non_final_records { + let payload = blob::encode(islock)?; + stmt.execute(params![ + wallet_id.as_slice(), + AsRef::<[u8]>::as_ref(txid), + payload + ])?; + } + } + if cs.last_processed_height.is_some() || cs.synced_height.is_some() { + upsert_sync_state(tx, wallet_id, cs.last_processed_height, cs.synced_height)?; + } + Ok(()) +} + +/// Resolve the owning account index for a UTXO by its rendered address, +/// joining against the `core_derived_addresses` map written earlier in +/// the same transaction. +const ACCOUNT_INDEX_BY_ADDRESS_SQL: &str = + "SELECT account_index FROM core_derived_addresses WHERE wallet_id = ?1 AND address = ?2"; + +const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) \ + ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \ + value = excluded.value, \ + script = excluded.script, \ + height = excluded.height, \ + account_index = excluded.account_index, \ + spent = excluded.spent"; + +fn execute_upsert_utxo( + stmt: &mut rusqlite::CachedStatement<'_>, + lookup_stmt: &mut rusqlite::CachedStatement<'_>, + wallet_id: &WalletId, + utxo: &Utxo, + spent: bool, +) -> Result<(), WalletStorageError> { + let op = blob::encode_outpoint(&utxo.outpoint); + let address = utxo.address.to_string(); + // `Utxo` carries no account index; recover it from the + // derived-address map written earlier in this transaction. + let account_index: i64 = lookup_stmt + .query_row(params![wallet_id.as_slice(), &address], |row| row.get(0)) + .optional()? + .unwrap_or_else(|| { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + address = %address, + "UTXO address not found in core_derived_addresses; defaulting account_index to 0" + ); + 0 + }); + stmt.execute(params![ + wallet_id.as_slice(), + &op[..], + crate::sqlite::util::safe_cast::u64_to_i64("core_utxos.value", utxo.value())?, + utxo.txout.script_pubkey.as_bytes(), + i64::from(utxo.height), + account_index, + spent, + ])?; + Ok(()) +} + +fn upsert_sync_state( + tx: &Transaction<'_>, + wallet_id: &WalletId, + last_processed: Option, + synced: Option, +) -> Result<(), WalletStorageError> { + // Monotonic-max semantics — keep the larger of (current, new). + let current_raw: (Option, Option) = tx + .query_row( + "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()? + .unwrap_or((None, None)); + let current = ( + sync_height_u32("core_sync_state.last_processed_height", current_raw.0)?, + sync_height_u32("core_sync_state.synced_height", current_raw.1)?, + ); + let lp = match (current.0, last_processed) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + }; + let sy = match (current.1, synced) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + }; + tx.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(wallet_id) DO UPDATE SET \ + last_processed_height = excluded.last_processed_height, \ + synced_height = excluded.synced_height", + params![wallet_id.as_slice(), lp.map(i64::from), sy.map(i64::from),], + )?; + Ok(()) +} + +/// Convert a stored sync-height column to `u32`, erroring on overflow +/// rather than silently truncating a corrupt/out-of-range value. +fn sync_height_u32( + field: &'static str, + value: Option, +) -> Result, WalletStorageError> { + match value { + None => Ok(None), + Some(v) => Ok(Some(u32::try_from(v).map_err(|_| { + WalletStorageError::IntegerOverflow { + field, + value: v as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + } + })?)), + } +} + +/// Fetch a single transaction record by txid. Returns `Ok(None)` if +/// absent. +pub fn get_tx_record( + conn: &Connection, + wallet_id: &WalletId, + txid: &dashcore::Txid, +) -> Result, WalletStorageError> { + let row: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)], + |row| row.get(0), + ) + .optional()?; + match row { + None => Ok(None), + Some(payload) => Ok(Some(blob::decode(&payload)?)), + } +} + +/// Row representing one unspent UTXO. Used by tests that probe the +/// `core_utxos` table without going through full `Wallet` reconstruction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnspentRow { + pub outpoint: dashcore::OutPoint, + pub value: u64, + pub script: Vec, + pub height: Option, + pub account_index: u32, +} + +/// All UTXOs for a wallet that have not been spent yet, bucketed by +/// account index. Used by `load` and tests. +pub fn list_unspent_utxos( + conn: &Connection, + wallet_id: &WalletId, +) -> Result>, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT outpoint, value, script, height, account_index \ + FROM core_utxos WHERE wallet_id = ?1 AND spent = 0", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let op_bytes: Vec = row.get(0)?; + let value: i64 = row.get(1)?; + let script: Vec = row.get(2)?; + let height: Option = row.get(3)?; + let account_index: i64 = row.get(4)?; + Ok((op_bytes, value, script, height, account_index)) + })?; + let mut by_account: BTreeMap> = BTreeMap::new(); + for r in rows { + let (op_bytes, value, script_bytes, height, account_index) = r?; + let outpoint = blob::decode_outpoint(&op_bytes)?; + let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; + let height = match height { + None => None, + Some(h) => Some( + u32::try_from(h).map_err(|_| WalletStorageError::IntegerOverflow { + field: "core_utxos.height", + value: h as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?, + ), + }; + let account_index = + u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { + field: "core_utxos.account_index", + value: account_index as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + let row = UnspentRow { + outpoint, + value, + script: script_bytes, + height, + account_index, + }; + by_account.entry(account_index).or_default().push(row); + } + Ok(by_account) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs new file mode 100644 index 00000000000..f38730372c8 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -0,0 +1,66 @@ +//! `dashpay_profiles` + `dashpay_payments_overlay` writers. + +use std::collections::BTreeMap; + +use rusqlite::{params, Transaction}; + +use dpp::prelude::Identifier; +use platform_wallet::wallet::identity::{DashPayProfile, PaymentEntry}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +/// V002: both dashpay tables are keyed by identity only; their FK +/// targets `identities(identity_id)` so cascade flows through the +/// `wallet_metadata → identities` chain. +/// +/// The `_wallet_id` parameter is kept on the signature for source +/// compatibility with the persister's `write_changeset_in_one_tx` +/// dispatch table, but it does not feed any column. +pub fn apply( + tx: &Transaction<'_>, + _wallet_id: &WalletId, + profiles: Option<&BTreeMap>>, + payments: Option<&BTreeMap>>, +) -> Result<(), WalletStorageError> { + if let Some(profiles) = profiles { + if !profiles.is_empty() { + let mut delete_stmt = + tx.prepare_cached("DELETE FROM dashpay_profiles WHERE identity_id = ?1")?; + let mut insert_stmt = tx.prepare_cached( + "INSERT INTO dashpay_profiles (identity_id, profile_blob) \ + VALUES (?1, ?2) \ + ON CONFLICT(identity_id) DO UPDATE SET profile_blob = excluded.profile_blob", + )?; + for (identity_id, profile) in profiles { + match profile { + None => { + delete_stmt.execute(params![identity_id.as_slice()])?; + } + Some(p) => { + let payload = blob::encode(p)?; + insert_stmt.execute(params![identity_id.as_slice(), payload])?; + } + } + } + } + } + if let Some(payments) = payments { + if !payments.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO dashpay_payments_overlay \ + (identity_id, payment_id, overlay_blob) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(identity_id, payment_id) DO UPDATE SET overlay_blob = excluded.overlay_blob", + )?; + for (identity_id, by_tx) in payments { + for (tx_id, entry) in by_tx { + let payload = blob::encode(entry)?; + stmt.execute(params![identity_id.as_slice(), tx_id, payload])?; + } + } + } + } + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs new file mode 100644 index 00000000000..83335995901 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -0,0 +1,215 @@ +//! `identities` table writer. + +use rusqlite::{params, Connection, Transaction}; + +use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &IdentityChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.identities.is_empty() { + // V002: PK is `identity_id` alone; `wallet_id` is nullable + // and links the identity to its parent wallet for cascade. + // The sentinel-zero wallet id (`[0u8; 32]`) is the legacy + // placeholder for "no parent wallet known" — stored as NULL + // so the FK to `wallet_metadata` doesn't activate. + // INTENTIONAL(SEC-001): NULL wallet_id allowed per CODE-002 design; + // COALESCE upsert is the intended merge semantic for orphan-identity-to-wallet promotion. + // Existing wallet_id is preserved on re-upsert; new wallet_id fills NULL. + let mut stmt = tx.prepare_cached( + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, ?3, ?4, 0) \ + ON CONFLICT(identity_id) DO UPDATE SET \ + wallet_id = COALESCE(excluded.wallet_id, identities.wallet_id), \ + wallet_index = excluded.wallet_index, \ + entry_blob = excluded.entry_blob, \ + tombstoned = 0", + )?; + let wallet_id_param = wallet_id_to_param(wallet_id); + for (id, entry) in &cs.identities { + let payload = blob::encode(entry)?; + stmt.execute(params![ + id.as_slice(), + wallet_id_param, + entry.identity_index.map(i64::from), + payload, + ])?; + } + } + if !cs.removed.is_empty() { + let mut stmt = + tx.prepare_cached("UPDATE identities SET tombstoned = 1 WHERE identity_id = ?1")?; + for id in &cs.removed { + stmt.execute(params![id.as_slice()])?; + } + } + Ok(()) +} + +/// V002: callers still receive a `WalletId` (32 bytes) from the +/// caller boundary. Treat the all-zero sentinel as "no parent wallet" +/// (NULL) so the nullable `identities.wallet_id` FK matches reality. +fn wallet_id_to_param(wallet_id: &WalletId) -> Option<&[u8]> { + if wallet_id.iter().all(|b| *b == 0) { + None + } else { + Some(wallet_id.as_slice()) + } +} + +/// Decode a single `identities` row back to its [`IdentityEntry`]. +/// +/// Returns `Ok(None)` if no row matches. This reads only `entry_blob` +/// and does NOT expose the `tombstoned` column — a tombstoned row still +/// decodes to `Some(entry)` here. Callers that must skip logically +/// deleted identities should use [`load_state`], which filters +/// tombstoned rows. +pub fn fetch( + conn: &Connection, + _wallet_id: &WalletId, + identity_id: &[u8; 32], +) -> Result, WalletStorageError> { + use rusqlite::OptionalExtension; + // V002: `identity_id` is the PK; the caller-supplied `wallet_id` + // is preserved on the signature for source-compatibility but is + // no longer part of the lookup key. + let row: Option> = conn + .query_row( + "SELECT entry_blob FROM identities WHERE identity_id = ?1", + params![&identity_id[..]], + |row| row.get(0), + ) + .optional()?; + match row { + None => Ok(None), + Some(payload) => Ok(Some(blob::decode(&payload)?)), + } +} + +/// Build a [`platform_wallet::changeset::IdentityManagerStartState`] +/// for one wallet from the `identities` table. Tombstoned rows are skipped (a logical delete, +/// not corruption); any row that fails to decode is a hard error — +/// corruption is never silently dropped. +/// +/// The bucket selection mirrors `IdentityManager`'s layout: +/// rows with `IdentityEntry.identity_index = Some(_)` go into +/// `wallet_identities[wallet_id]`; rows with `None` go into +/// `out_of_wallet_identities`. +pub fn load_state( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + use platform_wallet::changeset::IdentityManagerStartState; + + // V002: wallet_id is nullable on identities; this load path still + // wants only the rows belonging to the wallet the caller asked + // for, so the WHERE clause matches by wallet_id (orphan identities + // — wallet_id NULL — are out of scope for this per-wallet loader). + let mut stmt = conn.prepare( + "SELECT identity_id, entry_blob, tombstoned FROM identities WHERE wallet_id = ?1", + )?; + let mut state = IdentityManagerStartState::default(); + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let _identity_id: Vec = row.get(0)?; + let payload: Vec = row.get(1)?; + let tombstoned: i64 = row.get(2)?; + if tombstoned != 0 { + continue; + } + let entry: IdentityEntry = blob::decode(&payload)?; + let managed = managed_identity_from_entry(&entry, wallet_id); + match entry.identity_index { + Some(idx) => { + state + .wallet_identities + .entry(*wallet_id) + .or_default() + .insert(idx, managed); + } + None => { + state.out_of_wallet_identities.insert(entry.id, managed); + } + } + } + Ok(state) +} + +/// Reconstruct a [`ManagedIdentity`] from a persisted [`IdentityEntry`] +/// using a freshly minted V0 [`Identity`] for `(id, balance, revision)`. +/// Live runtime fields (contacts maps, public-key derivations) are +/// recovered separately via the contacts / identity_keys readers. +fn managed_identity_from_entry( + entry: &IdentityEntry, + wallet_id: &WalletId, +) -> platform_wallet::wallet::identity::ManagedIdentity { + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use platform_wallet::wallet::identity::ManagedIdentity; + let identity = Identity::V0(IdentityV0 { + id: entry.id, + public_keys: std::collections::BTreeMap::new(), + balance: entry.balance, + revision: entry.revision, + }); + ManagedIdentity { + identity, + identity_index: entry.identity_index, + last_updated_balance_block_time: entry.last_updated_balance_block_time, + last_synced_keys_block_time: entry.last_synced_keys_block_time, + established_contacts: Default::default(), + sent_contact_requests: Default::default(), + incoming_contact_requests: Default::default(), + status: entry.status, + dpns_names: entry.dpns_names.clone(), + contested_dpns_names: entry.contested_dpns_names.clone(), + wallet_id: entry.wallet_id.or(Some(*wallet_id)), + dashpay_profile: entry.dashpay_profile.clone(), + dashpay_payments: entry.dashpay_payments.clone(), + } +} + +/// Insert a stub identity row so identity_keys / dashpay_profiles can +/// reference it via their native composite FK. Used by tests that exercise +/// identity_keys persistence without going through the full identity +/// flow. The stub row carries a `null`-encoded `IdentityEntry` so the +/// `entry_blob` column always decodes — callers wanting real data +/// overwrite via [`apply`]. +pub fn ensure_exists( + conn: &Connection, + wallet_id: &WalletId, + identity_id: &[u8; 32], +) -> Result<(), WalletStorageError> { + use dpp::prelude::Identifier; + use platform_wallet::wallet::identity::IdentityStatus; + + let stub = IdentityEntry { + id: Identifier::from(*identity_id), + balance: 0, + revision: 0, + identity_index: None, + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Unknown, + wallet_id: None, + dashpay_profile: None, + dashpay_payments: Default::default(), + }; + let payload = blob::encode(&stub)?; + let wallet_id_param = wallet_id_to_param(wallet_id); + conn.execute( + "INSERT OR IGNORE INTO identities \ + (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, NULL, ?3, 0)", + params![&identity_id[..], wallet_id_param, payload], + )?; + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs new file mode 100644 index 00000000000..a08e87beb56 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -0,0 +1,178 @@ +//! `identity_keys` table writer (PUBLIC material only — see NFR-10). +//! +//! `IdentityKeyEntry`'s `public_key: dpp::IdentityPublicKey` uses +//! `#[serde(tag = "$formatVersion")]` on the parent enum, which +//! bincode-serde rejects (it requires `deserialize_any`). The other +//! fields are plain serde-compatible types. To keep the +//! "one blob per row" property we transcribe the entry into a wire +//! shape where the public key is bincode-2-native-encoded (the dpp +//! types derive `Encode`/`Decode`) and the surrounding fields ride +//! the bincode-serde encoder. The shape is documented on the +//! `IdentityKeyWire` struct below. + +use rusqlite::{params, Transaction}; +use serde::{Deserialize, Serialize}; + +use dpp::identity::{IdentityPublicKey, KeyID}; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +/// On-disk wire shape for `IdentityKeyEntry`. The `public_key` field +/// is pre-encoded via bincode 2's native `Encode/Decode` impls on +/// `dpp::IdentityPublicKey` so bincode-serde doesn't trip on dpp's +/// `serde(tag = ...)` representation. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct IdentityKeyWire { + identity_id: Identifier, + key_id: KeyID, + public_key_bincode: Vec, + public_key_hash: [u8; 20], + wallet_id: Option<[u8; 32]>, + derivation_indices: Option, +} + +impl IdentityKeyWire { + fn from_entry(entry: &IdentityKeyEntry) -> Result { + let pk = bincode::encode_to_vec(&entry.public_key, bincode::config::standard())?; + Ok(Self { + identity_id: entry.identity_id, + key_id: entry.key_id, + public_key_bincode: pk, + public_key_hash: entry.public_key_hash, + wallet_id: entry.wallet_id, + derivation_indices: entry.derivation_indices, + }) + } + + fn into_entry(self) -> Result { + let (public_key, consumed): (IdentityPublicKey, usize) = + bincode::decode_from_slice(&self.public_key_bincode, bincode::config::standard())?; + // CMT-009: consistent with the outer blob::decode trailing-byte + // guard. A valid-prefix + trailing-garbage payload that + // bincode's decoder happily accepts (it stops after the typed + // length) is corruption / forward-schema drift — refuse it. + if consumed != self.public_key_bincode.len() { + return Err(WalletStorageError::blob_decode( + "unexpected trailing bytes in identity_keys.public_key_bincode", + )); + } + Ok(IdentityKeyEntry { + identity_id: self.identity_id, + key_id: self.key_id, + public_key, + public_key_hash: self.public_key_hash, + wallet_id: self.wallet_id, + derivation_indices: self.derivation_indices, + }) + } +} + +/// V002: `identity_keys` is now keyed by `(identity_id, key_id)` +/// only; the parent FK points at `identities(identity_id)`. The +/// caller still passes a [`WalletId`] for source compatibility — it +/// is consulted only to validate the entry's own `wallet_id` field +/// (when set), keeping the entry-blob and typed columns aligned. +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &IdentityKeysChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.upserts.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO identity_keys \ + (identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, ?3, ?4, NULL) \ + ON CONFLICT(identity_id, key_id) DO UPDATE SET \ + public_key_blob = excluded.public_key_blob, \ + public_key_hash = excluded.public_key_hash, \ + derivation_blob = NULL", + )?; + for ((identity_id, key_id), entry) in &cs.upserts { + // Reject any disagreement between the map key / outer + // wallet_id (informational scope) and the entry fields + // (what the serialized blob carries) so the two + // representations of a row can never diverge on disk. + if entry.identity_id != *identity_id || entry.key_id != *key_id { + return Err(WalletStorageError::IdentityKeyEntryMismatch); + } + if let Some(entry_wallet_id) = entry.wallet_id { + // Treat the all-zero sentinel scope as "any wallet" so + // identity-only callers (no parent wallet) don't trip + // the cross-check. + let scope_is_sentinel = wallet_id.iter().all(|b| *b == 0); + if !scope_is_sentinel && entry_wallet_id != *wallet_id { + return Err(WalletStorageError::IdentityKeyEntryMismatch); + } + } + let wire = IdentityKeyWire::from_entry(entry)?; + let entry_blob = blob::encode(&wire)?; + stmt.execute(params![ + identity_id.as_slice(), + i64::from(*key_id), + entry_blob, + &entry.public_key_hash[..], + ])?; + } + } + if !cs.removed.is_empty() { + let mut stmt = + tx.prepare_cached("DELETE FROM identity_keys WHERE identity_id = ?1 AND key_id = ?2")?; + for (identity_id, key_id) in &cs.removed { + stmt.execute(params![identity_id.as_slice(), i64::from(*key_id)])?; + } + } + Ok(()) +} + +/// Decode an `identity_keys.public_key_blob` cell back to the entry. +pub fn decode_entry(payload: &[u8]) -> Result { + let wire: IdentityKeyWire = blob::decode(payload)?; + wire.into_entry() +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + + /// CMT-009: a `public_key_bincode` payload whose IdentityPublicKey + /// prefix is valid but carries trailing garbage is refused at + /// decode time rather than silently dropping the trailing bytes. + #[test] + fn into_entry_rejects_trailing_bytes_in_public_key_bincode() { + let pk = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }); + let mut pk_bincode = bincode::encode_to_vec(&pk, bincode::config::standard()).unwrap(); + pk_bincode.push(0xFF); // trailing garbage past the typed length + + let wire = IdentityKeyWire { + identity_id: dpp::prelude::Identifier::from([0xAA; 32]), + key_id: 0, + public_key_bincode: pk_bincode, + public_key_hash: [0u8; 20], + wallet_id: None, + derivation_indices: None, + }; + let err = wire.into_entry().expect_err("trailing bytes must error"); + assert!( + matches!(err, WalletStorageError::BlobDecode { .. }), + "expected BlobDecode for trailing-byte garbage, got {err:?}" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs new file mode 100644 index 00000000000..92beebbf1ab --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -0,0 +1,84 @@ +//! Per-area SQLite writers + readers. +//! +//! Each submodule owns one table or a small cluster (e.g. `contacts` +//! owns three). Writers take a `&rusqlite::Transaction` and an already +//! resolved sub-changeset; readers take `&rusqlite::Connection`. +//! +//! Encoding policy: scalars that fan out to per-row indexes go into +//! typed SQLite columns (heights, hashes, outpoints, flags). The +//! `_blob` columns carry the full sub-changeset entry encoded with +//! `bincode::serde::encode_to_vec` against the serde-derived types in +//! `platform-wallet` — see [`blob::encode`] / [`blob::decode`]. +//! Schema evolution is gated by the refinery migration version on +//! the database; individual blobs have no inline revision tag. + +pub mod accounts; +pub mod asset_locks; +pub mod blob; +pub mod contacts; +pub mod core_state; +pub mod dashpay; +pub mod identities; +pub mod identity_keys; +pub mod platform_addrs; +pub mod token_balances; +pub mod wallet_meta; + +/// How a per-wallet table is row-scoped against a `wallet_id`. After +/// the V002 schema migration (CODE-002), identity-owned tables drop +/// their direct `wallet_id` column and reach the parent wallet only +/// via the cascading FK chain `wallet_metadata → identities → …`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalletScope { + /// The table carries a `wallet_id` column directly; predicates + /// like `WHERE wallet_id = ?` work as-is. + DirectColumn, + /// The table is keyed by `identity_id`; lookups by wallet must + /// JOIN through `identities` (`SELECT … WHERE identity_id IN + /// (SELECT identity_id FROM identities WHERE wallet_id = ?)`). + ViaIdentity, +} + +/// Every per-wallet table — used by `delete_wallet` to count + cascade +/// row removal and by `inspect` for the table summary. `wallet_metadata` +/// is the parent and listed first; everything after it depends on the +/// parent row via the native `ON DELETE CASCADE` foreign keys declared +/// in `V001__initial.rs` (wallet-scoped tables) and +/// `V002__cascade_only_identity_refs.rs` (identity-scoped tables). +pub const PER_WALLET_TABLES: &[(&str, WalletScope)] = &[ + ("wallet_metadata", WalletScope::DirectColumn), + ("account_registrations", WalletScope::DirectColumn), + ("account_address_pools", WalletScope::DirectColumn), + ("core_transactions", WalletScope::DirectColumn), + ("core_utxos", WalletScope::DirectColumn), + ("core_instant_locks", WalletScope::DirectColumn), + ("core_derived_addresses", WalletScope::DirectColumn), + ("core_sync_state", WalletScope::DirectColumn), + ("identities", WalletScope::DirectColumn), + ("identity_keys", WalletScope::ViaIdentity), + ("contacts_sent", WalletScope::DirectColumn), + ("contacts_recv", WalletScope::DirectColumn), + ("contacts_established", WalletScope::DirectColumn), + ("platform_addresses", WalletScope::DirectColumn), + ("platform_address_sync", WalletScope::DirectColumn), + ("asset_locks", WalletScope::DirectColumn), + ("token_balances", WalletScope::ViaIdentity), + ("dashpay_profiles", WalletScope::ViaIdentity), + ("dashpay_payments_overlay", WalletScope::ViaIdentity), +]; + +/// SQL fragment for counting rows of `table` belonging to a single +/// wallet. `scope` selects the predicate flavour. The fragment includes +/// the leading `SELECT COUNT(*) FROM` so the call site can format it +/// directly and bind a single `?1` parameter (the wallet id bytes). +pub fn count_rows_for_wallet_sql(table: &str, scope: WalletScope) -> String { + match scope { + WalletScope::DirectColumn => { + format!("SELECT COUNT(*) FROM {table} WHERE wallet_id = ?1") + } + WalletScope::ViaIdentity => format!( + "SELECT COUNT(*) FROM {table} \ + WHERE identity_id IN (SELECT identity_id FROM identities WHERE wallet_id = ?1)" + ), + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs new file mode 100644 index 00000000000..2e059f9c83c --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs @@ -0,0 +1,237 @@ +//! `platform_addresses` + `platform_address_sync` writers. + +use rusqlite::{params, Connection, OptionalExtension, Transaction}; + +use dash_sdk::platform::address_sync::AddressFunds; +use key_wallet::PlatformP2PKHAddress; +use platform_wallet::changeset::PlatformAddressChangeSet; +use platform_wallet::changeset::PlatformAddressSyncStartState; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::util::safe_cast; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &PlatformAddressChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.addresses.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO platform_addresses \ + (wallet_id, account_index, address_index, address, balance, nonce) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(wallet_id, address) DO UPDATE SET \ + account_index = excluded.account_index, \ + address_index = excluded.address_index, \ + balance = excluded.balance, \ + nonce = excluded.nonce", + )?; + for entry in &cs.addresses { + // The row is keyed by the outer `wallet_id`; an entry that + // names a different wallet would otherwise be mis-filed. The + // native FK also rejects an unknown parent, but this typed + // error pinpoints the mismatch instead of surfacing a raw + // FOREIGN KEY failure. + if entry.wallet_id != *wallet_id { + return Err(WalletStorageError::WalletIdMismatch { + expected: *wallet_id, + found: entry.wallet_id, + }); + } + stmt.execute(params![ + wallet_id.as_slice(), + i64::from(entry.account_index), + i64::from(entry.address_index), + entry.address.as_bytes(), + safe_cast::u64_to_i64("platform_addresses.balance", entry.funds.balance)?, + i64::from(entry.funds.nonce), + ])?; + } + } + if cs.sync_height.is_some() + || cs.sync_timestamp.is_some() + || cs.last_known_recent_block.is_some() + { + let current: Option<(i64, i64, i64)> = tx + .query_row( + "SELECT sync_height, sync_timestamp, last_known_recent_block \ + FROM platform_address_sync WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let (cur_h, cur_t, cur_r) = match current { + Some((h, t, r)) => ( + safe_cast::i64_to_u64("platform_address_sync.sync_height", h)?, + safe_cast::i64_to_u64("platform_address_sync.sync_timestamp", t)?, + safe_cast::i64_to_u64("platform_address_sync.last_known_recent_block", r)?, + ), + None => (0u64, 0u64, 0u64), + }; + let h = cs.sync_height.map(|x| x.max(cur_h)).unwrap_or(cur_h); + let t = cs.sync_timestamp.map(|x| x.max(cur_t)).unwrap_or(cur_t); + let r = cs + .last_known_recent_block + .map(|x| x.max(cur_r)) + .unwrap_or(cur_r); + tx.execute( + "INSERT INTO platform_address_sync \ + (wallet_id, sync_height, sync_timestamp, last_known_recent_block) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id) DO UPDATE SET \ + sync_height = excluded.sync_height, \ + sync_timestamp = excluded.sync_timestamp, \ + last_known_recent_block = excluded.last_known_recent_block", + params![ + wallet_id.as_slice(), + safe_cast::u64_to_i64("platform_address_sync.sync_height", h)?, + safe_cast::u64_to_i64("platform_address_sync.sync_timestamp", t)?, + safe_cast::u64_to_i64("platform_address_sync.last_known_recent_block", r)?, + ], + )?; + } + Ok(()) +} + +/// Row from `platform_addresses` keyed by wallet for tests/load. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformAddressRow { + pub account_index: u32, + pub address_index: u32, + pub address: PlatformP2PKHAddress, + pub funds: AddressFunds, +} + +pub fn list_per_wallet( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT account_index, address_index, address, balance, nonce \ + FROM platform_addresses WHERE wallet_id = ?1 \ + ORDER BY account_index, address_index, address", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let account_index: i64 = row.get(0)?; + let address_index: i64 = row.get(1)?; + let address_bytes: Vec = row.get(2)?; + let balance: i64 = row.get(3)?; + let nonce: i64 = row.get(4)?; + Ok((account_index, address_index, address_bytes, balance, nonce)) + })?; + let mut out = Vec::new(); + for r in rows { + let (account_index, address_index, address_bytes, balance, nonce) = r?; + if address_bytes.len() != 20 { + return Err(WalletStorageError::blob_decode( + "platform_addresses.address column is not 20 bytes", + )); + } + let mut hash160 = [0u8; 20]; + hash160.copy_from_slice(&address_bytes); + let balance = safe_cast::i64_to_u64("platform_addresses.balance", balance)?; + let nonce = u32::try_from(nonce).map_err(|_| WalletStorageError::IntegerOverflow { + field: "platform_addresses.nonce", + value: nonce as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + let account_index = + u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { + field: "platform_addresses.account_index", + value: account_index as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + let address_index = + u32::try_from(address_index).map_err(|_| WalletStorageError::IntegerOverflow { + field: "platform_addresses.address_index", + value: address_index as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + out.push(PlatformAddressRow { + account_index, + address_index, + address: PlatformP2PKHAddress::new(hash160), + funds: AddressFunds { balance, nonce }, + }); + } + Ok(out) +} + +/// Build `PlatformAddressSyncStartState` for a wallet. The +/// `per_account` portion is left at its `Default` value because +/// reconstructing `PerWalletPlatformAddressState` requires xpubs the +/// persister doesn't currently round-trip into the live provider — the +/// load-side wiring upstream is the consumer of this struct. +pub fn load_state( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let row: Option<(i64, i64, i64)> = conn + .query_row( + "SELECT sync_height, sync_timestamp, last_known_recent_block \ + FROM platform_address_sync WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let (h, t, r) = match row { + Some((h, t, r)) => ( + safe_cast::i64_to_u64("platform_address_sync.sync_height", h)?, + safe_cast::i64_to_u64("platform_address_sync.sync_timestamp", t)?, + safe_cast::i64_to_u64("platform_address_sync.last_known_recent_block", r)?, + ), + None => (0u64, 0u64, 0u64), + }; + Ok(PlatformAddressSyncStartState { + per_account: Default::default(), + sync_height: h, + sync_timestamp: t, + last_known_recent_block: r, + }) +} + +/// Total `platform_addresses` row count per wallet — used by tests +/// that want a stable lower-bound check without re-deriving the +/// address. +pub fn count_per_wallet( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let n: i64 = conn.query_row( + "SELECT COUNT(*) FROM platform_addresses WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + )?; + Ok(usize::try_from(n).unwrap_or(usize::MAX)) +} + +/// One row of [`load_all`] aggregated state per wallet: +/// `(sync_state, address_row_count)`. +/// +/// `address_row_count` mirrors what [`count_per_wallet`] would return — +/// folding the count into the bulk scan saves a per-wallet query. +pub type LoadAllEntry = (PlatformAddressSyncStartState, usize); + +/// Bulk reader for `load()`: one [`load_state`] + [`count_per_wallet`] +/// pair per wallet id listed in `wallet_metadata`. Constant-query +/// w.r.t. the number of wallets per call site (FR-P4-6). +/// +/// Driven by [`wallet_meta::list_ids`](crate::sqlite::schema::wallet_meta::list_ids): +/// orphaned `platform_addresses` / `platform_address_sync` rows whose +/// `wallet_id` is absent from `wallet_metadata` are intentionally NOT +/// surfaced. Native foreign keys prevent such orphans; a future re-wire +/// that needs them must restore the id-union over the area tables. +pub fn load_all( + conn: &Connection, +) -> Result, WalletStorageError> { + use std::collections::BTreeMap; + + let mut out: BTreeMap = BTreeMap::new(); + for wallet_id in crate::sqlite::schema::wallet_meta::list_ids(conn)? { + let sync = load_state(conn, &wallet_id)?; + let count = count_per_wallet(conn, &wallet_id)?; + out.insert(wallet_id, (sync, count)); + } + Ok(out) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs new file mode 100644 index 00000000000..0560c431c81 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs @@ -0,0 +1,53 @@ +//! `token_balances` table writer. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::TokenBalanceChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::util::safe_cast; + +/// V002: `token_balances` is now keyed by `(identity_id, token_id)` +/// only. The caller still supplies a [`WalletId`] for source +/// compatibility — it is unused on this writer because cascade flows +/// `wallet_metadata → identities → token_balances` through the +/// nullable `identities.wallet_id` FK. +// INTENTIONAL(SEC-002): orphan token_balances cleanup is host responsibility. +// No automatic prune API is provided — V002 cascades through identities only, +// not through wallet_id (which was dropped from this table). Hosts that delete +// identities out-of-band must prune token_balances themselves. +pub fn apply( + tx: &Transaction<'_>, + _wallet_id: &WalletId, + cs: &TokenBalanceChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.balances.is_empty() { + let now = chrono::Utc::now().timestamp(); + let mut stmt = tx.prepare_cached( + "INSERT INTO token_balances \ + (identity_id, token_id, balance, updated_at) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(identity_id, token_id) DO UPDATE SET \ + balance = excluded.balance, \ + updated_at = excluded.updated_at", + )?; + for ((identity_id, token_id), balance) in &cs.balances { + stmt.execute(params![ + identity_id.as_slice(), + token_id.as_slice(), + safe_cast::u64_to_i64("token_balances.balance", *balance)?, + now, + ])?; + } + } + if !cs.removed_balances.is_empty() { + let mut stmt = tx.prepare_cached( + "DELETE FROM token_balances WHERE identity_id = ?1 AND token_id = ?2", + )?; + for (identity_id, token_id) in &cs.removed_balances { + stmt.execute(params![identity_id.as_slice(), token_id.as_slice()])?; + } + } + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs new file mode 100644 index 00000000000..a5e590c234c --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs @@ -0,0 +1,108 @@ +//! `wallet_metadata` writer + helpers. + +use rusqlite::{params, Connection, Transaction}; + +use platform_wallet::changeset::WalletMetadataEntry; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; + +/// Insert / replace a `wallet_metadata` row. +pub fn upsert( + tx: &Transaction<'_>, + wallet_id: &WalletId, + entry: &WalletMetadataEntry, +) -> Result<(), WalletStorageError> { + let network = network_to_str(entry.network); + let mut stmt = tx.prepare_cached( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(wallet_id) DO UPDATE SET network = excluded.network, \ + birth_height = excluded.birth_height", + )?; + stmt.execute(params![wallet_id.as_slice(), network, entry.birth_height])?; + Ok(()) +} + +/// Ensure a `wallet_metadata` parent row exists for the given id. Used +/// by tests that exercise persistence without going through registration. +/// +/// Idempotent — silently a no-op when the row already exists. Defaults +/// `network = "testnet"`, `birth_height = 0` (the same fall-back the +/// SPV scan uses when the chain tip is unknown). +pub fn ensure_exists(conn: &Connection, wallet_id: &WalletId) -> Result<(), WalletStorageError> { + conn.execute( + "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, ?2, ?3)", + params![wallet_id.as_slice(), "testnet", 0i64], + )?; + Ok(()) +} + +/// All known wallet ids (used by `delete_wallet`, `load`, `inspect`). +pub fn list_ids(conn: &Connection) -> Result, WalletStorageError> { + let mut stmt = conn.prepare("SELECT wallet_id FROM wallet_metadata ORDER BY wallet_id")?; + let rows = stmt.query_map([], |row| row.get::<_, Vec>(0))?; + let mut out = Vec::new(); + for r in rows { + let bytes = r?; + let wid = <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| { + WalletStorageError::InvalidWalletIdLength { + actual: bytes.len(), + } + })?; + out.push(wid); + } + Ok(out) +} + +/// Lookup `(network, birth_height)` for a wallet, if known. +pub fn fetch( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut stmt = + conn.prepare("SELECT network, birth_height FROM wallet_metadata WHERE wallet_id = ?1")?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + if let Some(row) = rows.next()? { + let network: String = row.get(0)?; + let height: i64 = row.get(1)?; + let height = u32::try_from(height).map_err(|_| WalletStorageError::IntegerOverflow { + field: "wallet_metadata.birth_height", + value: height as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + Ok(Some((network, height))) + } else { + Ok(None) + } +} + +/// Delete a wallet_metadata row (native `ON DELETE CASCADE` fires). +pub fn delete(tx: &Transaction<'_>, wallet_id: &WalletId) -> Result { + let n = tx.execute( + "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + )?; + Ok(n) +} + +fn network_to_str(net: key_wallet::Network) -> &'static str { + match net { + key_wallet::Network::Mainnet => "mainnet", + key_wallet::Network::Testnet => "testnet", + key_wallet::Network::Devnet => "devnet", + key_wallet::Network::Regtest => "regtest", + } +} + +/// Inverse of `network_to_str`. +pub fn parse_network(s: &str) -> Option { + match s { + "mainnet" => Some(key_wallet::Network::Mainnet), + "testnet" => Some(key_wallet::Network::Testnet), + "devnet" => Some(key_wallet::Network::Devnet), + "regtest" => Some(key_wallet::Network::Regtest), + _ => None, + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs new file mode 100644 index 00000000000..921ef15f9a4 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs @@ -0,0 +1,4 @@ +//! Shared internal helpers (safe casts, file permissions, etc.). + +pub mod permissions; +pub mod safe_cast; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs new file mode 100644 index 00000000000..b64525586db --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs @@ -0,0 +1,48 @@ +//! SEC-004 / SEC-011: chmod helpers for newly created DB files. +//! +//! Restricts the on-disk SQLite files (live DB, backup copies, restored +//! DB) to owner-only on Unix so the mode never depends on the calling +//! process's umask. Windows has no equivalent permission model here and +//! is a no-op. + +use std::path::Path; + +use crate::sqlite::error::WalletStorageError; + +/// Apply owner-only (`0o600`) permissions to `path` on Unix, plus its +/// `-wal` / `-shm` SQLite sidecars when present. Siblings that don't +/// exist are skipped silently — they're only created on demand by +/// SQLite's WAL journaling mode. No-op on non-Unix platforms. +#[allow(unused_variables)] // `path` is unused on non-Unix. +pub fn apply_secure_permissions(path: &Path) -> Result<(), WalletStorageError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(path, perms.clone())?; + // SEC-004: WAL mode is the default for this crate, so recent + // committed pages live in -wal / -shm. Without this + // sweep, the sidecars stay at the process umask default — a + // local-user info leak on multi-user hosts. + // + // CODE-011: build the sibling path via `OsString::push` so + // non-UTF-8 path bytes survive intact (no `to_string_lossy` + // corruption). `set_permissions` runs unconditionally — a + // missing sibling returns `ErrorKind::NotFound`, which we treat + // as a silent no-op (closes the `exists()` TOCTOU gate). + let Some(file_name) = path.file_name() else { + return Ok(()); + }; + for ext in ["-wal", "-shm"] { + let mut sibling_name = file_name.to_os_string(); + sibling_name.push(ext); + let sibling = path.with_file_name(sibling_name); + match std::fs::set_permissions(&sibling, perms.clone()) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(WalletStorageError::Io(e)), + } + } + } + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs new file mode 100644 index 00000000000..c02632913b2 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs @@ -0,0 +1,98 @@ +//! Safe integer conversions for the SQLite `INTEGER` column boundary. +//! +//! SQLite's `INTEGER` affinity is `i64`. Rust's wallet types (credits +//! balances, durations cast to milliseconds, monotonic-max heights, +//! token balances) are `u64`. Naively `as i64` casting wraps values +//! ≥ `i64::MAX` to negative numbers and silently sign-extends them +//! back to large `u64` on read. +//! +//! Every cross-boundary cast in the writer / reader paths runs through +//! one of these helpers and produces a typed +//! [`WalletStorageError::IntegerOverflow`] on out-of-range input. +//! `clippy::cast_possible_wrap` and `cast_sign_loss` warnings stay +//! allowed crate-wide because many in-crate casts are bounded (e.g. +//! `u8` tags, `u32` indices ≤ `i32::MAX`); the contract is that +//! *durable boundary casts* go through this module. + +use crate::sqlite::error::WalletStorageError; + +/// The target type whose range was exceeded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum SafeCastTarget { + #[error("i64")] + I64, + #[error("u64")] + U64, +} + +/// Cast `value: u64` to `i64`, surfacing +/// [`WalletStorageError::IntegerOverflow`] when the value exceeds +/// `i64::MAX`. +/// +/// `field` is a compile-time identifier (e.g. `"asset_locks.amount_duffs"`) +/// naming the column so the resulting error is actionable. +pub fn u64_to_i64(field: &'static str, value: u64) -> Result { + i64::try_from(value).map_err(|_| WalletStorageError::IntegerOverflow { + field, + value, + target: SafeCastTarget::I64, + }) +} + +/// Cast `value: i64` to `u64`, surfacing +/// [`WalletStorageError::IntegerOverflow`] when the database stored +/// a negative value (possible if a previous build wrote a wrapped +/// value before this helper existed). +pub fn i64_to_u64(field: &'static str, value: i64) -> Result { + u64::try_from(value).map_err(|_| WalletStorageError::IntegerOverflow { + field, + // For negative inputs the wrapped representation is what we + // surface — the operator looks at the original bits, not the + // post-cast u64 garbage. + value: value as u64, + target: SafeCastTarget::U64, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn u64_to_i64_happy_path() { + assert_eq!(u64_to_i64("x", 0).unwrap(), 0); + assert_eq!(u64_to_i64("x", i64::MAX as u64).unwrap(), i64::MAX); + } + + #[test] + fn u64_to_i64_overflow() { + let err = u64_to_i64("balance", u64::MAX).unwrap_err(); + assert!(matches!( + err, + WalletStorageError::IntegerOverflow { + field: "balance", + value: u64::MAX, + target: SafeCastTarget::I64, + } + )); + } + + #[test] + fn i64_to_u64_happy_path() { + assert_eq!(i64_to_u64("x", 0).unwrap(), 0); + assert_eq!(i64_to_u64("x", i64::MAX).unwrap(), i64::MAX as u64); + } + + #[test] + fn i64_to_u64_overflow_on_negative() { + let err = i64_to_u64("balance", -1).unwrap_err(); + assert!(matches!( + err, + WalletStorageError::IntegerOverflow { + field: "balance", + target: SafeCastTarget::U64, + .. + } + )); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/common/mod.rs b/packages/rs-platform-wallet-storage/tests/common/mod.rs new file mode 100644 index 00000000000..044ba58c78f --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/common/mod.rs @@ -0,0 +1,89 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Shared test helpers for the SQLite persister integration tests. + +#![allow(dead_code)] + +use std::path::PathBuf; + +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::Connection; + +pub use platform_wallet_storage::{FlushMode, SqlitePersister, SqlitePersisterConfig}; + +/// Open an empty temp directory + persister for one test. Returns the +/// persister, the keep-alive `tempfile::TempDir`, and the DB path. +pub fn fresh_persister() -> (SqlitePersister, tempfile::TempDir, PathBuf) { + fresh_persister_with_mode(FlushMode::Immediate) +} + +pub fn fresh_persister_with_mode(mode: FlushMode) -> (SqlitePersister, tempfile::TempDir, PathBuf) { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("wallet.db"); + let cfg = SqlitePersisterConfig::new(&path).with_flush_mode(mode); + let p = SqlitePersister::open(cfg).expect("open persister"); + (p, tmp, path) +} + +/// Wallet id helper. +pub fn wid(byte: u8) -> WalletId { + [byte; 32] +} + +/// Open a read-only side connection — used by tests that probe the DB +/// while the persister still owns the write conn. +pub fn ro_conn(path: &std::path::Path) -> Connection { + Connection::open_with_flags( + path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("open ro conn") +} + +/// Insert a stub `wallet_metadata` row so child writes pass the native +/// FK. Bypasses the buffer/flush layer — tests use this when they +/// want to exercise a single sub-changeset writer in isolation. +pub fn ensure_wallet_meta(persister: &SqlitePersister, wallet_id: &WalletId) { + use rusqlite::params; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .expect("ensure wallet_metadata"); +} + +/// Insert a stub `identities` row so identity-owned table writes +/// (`token_balances`, `dashpay_profiles`, `identity_keys`) pass the +/// V002 FK to `identities(identity_id)`. `parent_wallet_id` is +/// optional — when `Some`, the row is linked to that wallet so the +/// cascade chain works; when `None`, the row is an orphan identity +/// (NULL `wallet_id`), still satisfying the identity-owned FKs. +pub fn ensure_identity( + persister: &SqlitePersister, + identity_id: &[u8; 32], + parent_wallet_id: Option<&WalletId>, +) { + use rusqlite::params; + let conn = persister.lock_conn_for_test(); + let wid_param: Option<&[u8]> = parent_wallet_id.map(|w| w.as_slice()); + conn.execute( + "INSERT OR IGNORE INTO identities \ + (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, NULL, X'00', 0)", + params![&identity_id[..], wid_param], + ) + .expect("ensure identity"); +} + +/// Echo a simple `store` + `flush` of an arbitrary changeset. +pub fn store_and_flush( + persister: &SqlitePersister, + wallet_id: WalletId, + cs: platform_wallet::changeset::PlatformWalletChangeSet, +) { + persister.store(wallet_id, cs).expect("store"); + persister.flush(wallet_id).expect("flush"); +} diff --git a/packages/rs-platform-wallet-storage/tests/feature_flag_build.rs b/packages/rs-platform-wallet-storage/tests/feature_flag_build.rs new file mode 100644 index 00000000000..de7b310f248 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/feature_flag_build.rs @@ -0,0 +1,53 @@ +//! TC-CODE-020-1 — feature-flag boundary check. +//! +//! Asserts that without the `sqlite` feature the crate compiles with a +//! minimal cross-cutting surface only (`WalletStorageError` is NOT +//! reachable; `SqlitePersister` is NOT reachable). The dev-deps for +//! this crate enable `sqlite`, so this file's purpose is to surface a +//! regression at *review time* by reading the manifest and the lib.rs +//! gating layout — it does NOT re-build the crate. +//! +//! The bare-build invariant itself is enforced by `cargo build +//! -p platform-wallet-storage --no-default-features` in CI; this test +//! pins the source-level expectations so the gate stays meaningful. + +#[test] +fn tc_code_020_1_sqlite_items_are_feature_gated() { + let lib_src = include_str!("../src/lib.rs"); + assert!( + lib_src.contains(r#"#[cfg(feature = "sqlite")]"#), + "lib.rs MUST gate sqlite re-exports behind the `sqlite` feature" + ); + assert!( + lib_src.contains( + r#"#[cfg(feature = "sqlite")] +pub mod sqlite;"# + ), + "the `sqlite` module declaration MUST be cfg-gated" + ); +} + +#[test] +fn tc_code_020_1_wallet_and_serde_deps_are_optional() { + let manifest = include_str!("../Cargo.toml"); + // Both must be tagged optional so a bare build does NOT pull + // platform-wallet (which transitively brings dpp / drive / dashcore) + // or serde. + assert!( + manifest.contains("platform-wallet = { path = \"../rs-platform-wallet\", features = [\n \"serde\",\n], optional = true }"), + "platform-wallet MUST be optional (gated by the `sqlite` feature)" + ); + assert!( + manifest.contains("serde = { version = \"1\", features = [\"derive\"], optional = true }"), + "serde MUST be optional (gated by the `sqlite` feature)" + ); + // The sqlite feature MUST pull both back in. + assert!( + manifest.contains("\"dep:platform-wallet\""), + "the sqlite feature MUST activate dep:platform-wallet" + ); + assert!( + manifest.contains("\"dep:serde\""), + "the sqlite feature MUST activate dep:serde" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs new file mode 100644 index 00000000000..b5a0745baf6 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs @@ -0,0 +1,379 @@ +//! `WalletStorageError -> PersistenceError` kind-classification table +//! (CODE-004). +//! +//! TC-CODE-004-b — every `WalletStorageError` variant carries through +//! the boundary with the right `PersistenceErrorKind` (`Transient` / +//! `Fatal` / `Constraint`). The `From` impl in +//! `sqlite/error.rs` is the single source of truth; this test pins +//! the mapping so changes to it are deliberate. +//! +//! TC-CODE-004-e — `WalletStorageError::is_transient()` and +//! `WalletStorageError::error_kind_str()` must remain wildcard-free so +//! adding a new variant forces an explicit classification update. This +//! test parses the source file and refuses to compile around a `_ =>` +//! arm in either method. + +use std::path::PathBuf; + +use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; +use platform_wallet_storage::sqlite::error::{AutoBackupOperation, WalletStorageError}; +use platform_wallet_storage::sqlite::util::safe_cast::SafeCastTarget; +use rusqlite::ErrorCode; + +/// Classify a converted `PersistenceError` to its `PersistenceErrorKind`. +/// Panics if the converted error is `LockPoisoned`, which is its own +/// trait-level variant rather than a `Backend { kind, .. }`. +fn kind_of(err: WalletStorageError) -> PersistenceErrorKind { + match PersistenceError::from(err) { + PersistenceError::Backend { kind, .. } => kind, + PersistenceError::LockPoisoned => { + panic!("LockPoisoned has no Backend.kind — test was given LockPoisoned by accident") + } + } +} + +fn sqlite_failure(code: ErrorCode, extended: i32) -> WalletStorageError { + WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code, + extended_code: extended, + }, + Some("simulated".into()), + )) +} + +/// TC-CODE-004-b — `LockPoisoned` keeps its dedicated variant. +#[test] +fn tc_code_004_b_lock_poisoned_maps_to_lock_poisoned() { + let pe: PersistenceError = WalletStorageError::LockPoisoned.into(); + assert!(matches!(pe, PersistenceError::LockPoisoned)); +} + +/// TC-CODE-004-b — every `is_transient() == true` variant maps to +/// `PersistenceErrorKind::Transient` at the trait boundary. +#[test] +fn tc_code_004_b_transient_variants_map_to_transient_kind() { + let transient_cases = [ + ("DatabaseBusy", sqlite_failure(ErrorCode::DatabaseBusy, 5)), + ( + "DatabaseLocked", + sqlite_failure(ErrorCode::DatabaseLocked, 6), + ), + ("DiskFull", sqlite_failure(ErrorCode::DiskFull, 13)), + ( + "SystemIoFailure", + sqlite_failure(ErrorCode::SystemIoFailure, 10), + ), + ("OutOfMemory", sqlite_failure(ErrorCode::OutOfMemory, 7)), + ( + "FlushRetryable", + WalletStorageError::FlushRetryable { + wallet_id: [0xAB; 32], + source: rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseBusy, + extended_code: 5, + }, + Some("busy".into()), + ), + }, + ), + ]; + + for (label, err) in transient_cases { + assert!( + err.is_transient(), + "{label}: WalletStorageError::is_transient must be true" + ); + assert_eq!( + kind_of(err), + PersistenceErrorKind::Transient, + "{label}: trait-boundary kind must be Transient" + ); + } +} + +/// TC-CODE-004-b — SQLite constraint failures map to +/// `PersistenceErrorKind::Constraint` so consumers can distinguish +/// "your data is wrong" from "the storage engine is unhappy". +#[test] +fn tc_code_004_b_constraint_variants_map_to_constraint_kind() { + let constraint_codes = [ + ErrorCode::ConstraintViolation, + // Specific constraint sub-codes covered by SQLite's extended + // error codes — checked via the outer ErrorCode group. + ]; + for code in constraint_codes { + let err = sqlite_failure(code, 19); + assert!( + !err.is_transient(), + "constraint must not be transient ({code:?})" + ); + assert_eq!( + kind_of(err), + PersistenceErrorKind::Constraint, + "{code:?} must map to Constraint" + ); + } +} + +/// TC-CODE-004-b — every remaining fatal-but-not-constraint variant +/// maps to `Fatal`. Spot-check enough variants to lock the table; the +/// exhaustiveness is guarded by the wildcard-free invariant test. +#[test] +fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { + let fatal_cases: Vec<(&str, WalletStorageError)> = vec![ + ("Io", WalletStorageError::Io(std::io::Error::other("io"))), + ( + "Sqlite-other", + WalletStorageError::Sqlite(rusqlite::Error::InvalidColumnIndex(0)), + ), + ( + "IntegrityCheckFailed", + WalletStorageError::IntegrityCheckFailed { + report: "bad".into(), + }, + ), + ( + "SchemaHistoryMissing", + WalletStorageError::SchemaHistoryMissing, + ), + ( + "SchemaVersionUnsupported", + WalletStorageError::SchemaVersionUnsupported { + found: 9, + max_supported: 2, + }, + ), + ( + "AutoBackupDisabled", + WalletStorageError::AutoBackupDisabled { + operation: AutoBackupOperation::DeleteWallet, + }, + ), + ( + "AutoBackupDirUnwritable", + WalletStorageError::AutoBackupDirUnwritable { + dir: PathBuf::from("/nope"), + source: std::io::Error::other("io"), + }, + ), + ( + "WalletNotFound", + WalletStorageError::WalletNotFound { + wallet_id: [0xCD; 32], + }, + ), + ( + "WalletIdMismatch", + WalletStorageError::WalletIdMismatch { + expected: [0xAA; 32], + found: [0xBB; 32], + }, + ), + ( + "RestoreDestinationLocked", + WalletStorageError::RestoreDestinationLocked, + ), + ( + "InvalidWalletIdLength", + WalletStorageError::InvalidWalletIdLength { actual: 12 }, + ), + ( + "ConfigInvalid", + WalletStorageError::ConfigInvalid { + reason: "synchronous=Off", + }, + ), + ( + "BlobDecode", + WalletStorageError::BlobDecode { reason: "len" }, + ), + ( + "ForeignKeysNotEnforced", + WalletStorageError::ForeignKeysNotEnforced, + ), + ( + "IdentityKeyEntryMismatch", + WalletStorageError::IdentityKeyEntryMismatch, + ), + ( + "BlobTooLarge", + WalletStorageError::BlobTooLarge { + len_bytes: 1, + limit_bytes: 0, + }, + ), + ( + "IntegerOverflow", + WalletStorageError::IntegerOverflow { + field: "x", + value: 1, + target: SafeCastTarget::I64, + }, + ), + ( + "BackupDestinationExists", + WalletStorageError::BackupDestinationExists { + path: PathBuf::from("/tmp/x"), + }, + ), + ]; + + for (label, err) in fatal_cases { + assert!(!err.is_transient(), "{label}: must not be transient"); + assert_eq!( + kind_of(err), + PersistenceErrorKind::Fatal, + "{label}: trait-boundary kind must be Fatal" + ); + } +} + +/// TC-CODE-004-b — the boxed source preserves the typed `Error` +/// chain so consumers can walk it (the trait was the right boundary +/// for `Box` precisely so the rusqlite +/// source is recoverable). The outer `Display` carries the variant +/// marker ops grep for; `.source()` walks to the inner `rusqlite` +/// payload. +#[test] +fn tc_code_004_b_source_preserves_inner_display_chain() { + let err = WalletStorageError::FlushRetryable { + wallet_id: [0xAB; 32], + source: rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseBusy, + extended_code: 5, + }, + Some("database is locked".into()), + ), + }; + let pe: PersistenceError = err.into(); + match pe { + PersistenceError::Backend { kind, source } => { + assert_eq!(kind, PersistenceErrorKind::Transient); + let outer = source.to_string(); + assert!(outer.contains("FlushRetryable"), "marker missing: {outer}"); + assert!( + outer.contains("flush failed transiently"), + "body missing: {outer}" + ); + assert!(outer.contains("abab"), "wallet_id hex missing: {outer}"); + + // Walk the typed source chain — that's the whole point of + // boxing the typed error rather than stringifying it. + let mut chain_text = String::new(); + let mut cur: Option<&(dyn std::error::Error + 'static)> = source.source(); + while let Some(e) = cur { + chain_text.push_str(&e.to_string()); + chain_text.push('\n'); + cur = e.source(); + } + assert!( + chain_text.contains("database is locked"), + "inner source text missing from chain walk: {chain_text}" + ); + } + other => panic!("expected Backend {{ .. }}, got {other:?}"), + } +} + +/// TC-CODE-004-e — `is_transient()` source must not regress to a +/// wildcard arm on its outer `match self`. The inner match on +/// `rusqlite::ErrorCode` is allowed to use a wildcard since +/// `ErrorCode` is `#[non_exhaustive]` upstream — we only guard the +/// outer `WalletStorageError` match. +#[test] +fn tc_code_004_e_is_transient_outer_match_is_wildcard_free() { + let src = include_str!("../src/sqlite/error.rs"); + let outer = extract_outer_match_self_body(src, "pub fn is_transient(&self) -> bool") + .expect("is_transient outer match body must be present"); + assert_no_wildcard(&outer, "is_transient"); +} + +/// TC-CODE-004-e — same guard for `error_kind_str()`. The outer match +/// over `WalletStorageError` MUST remain wildcard-free; the inner +/// match over `ErrorCode` may have its own wildcard. +#[test] +fn tc_code_004_e_error_kind_str_is_wildcard_free() { + let src = include_str!("../src/sqlite/error.rs"); + let outer = extract_outer_match_self_body(src, "pub fn error_kind_str(&self) -> &'static str") + .expect("error_kind_str outer match body must be present"); + assert_no_wildcard(&outer, "error_kind_str"); +} + +/// Locate the first `match self {` block inside `fn` `signature` and +/// return only its top-level body (arms at brace-depth = 0 relative +/// to the outer match). Nested matches and tuple patterns at deeper +/// depths are excluded so an inner `_ =>` on `ErrorCode` (which is +/// upstream-`#[non_exhaustive]`) doesn't trip the invariant. +fn extract_outer_match_self_body(src: &str, signature: &str) -> Option { + let start = src.find(signature)?; + let after_sig = &src[start..]; + // Find the `match self {` opening *after* the signature. The + // intervening braces from the fn body are handled by depth + // counting below. + let match_kw = after_sig.find("match self")?; + let open = after_sig[match_kw..].find('{')? + match_kw; + let bytes = after_sig.as_bytes(); + let mut depth = 0usize; + let mut top_level_arms = String::new(); + let mut i = open; + while i < bytes.len() { + let b = bytes[i]; + if b == b'{' { + depth += 1; + if depth > 1 { + // Skip past the nested block. + let close = find_matching_close(bytes, i)?; + i = close + 1; + depth -= 1; + continue; + } + } else if b == b'}' { + if depth == 1 { + return Some(top_level_arms); + } + depth -= 1; + } else if depth == 1 { + top_level_arms.push(b as char); + } + i += 1; + } + None +} + +fn find_matching_close(bytes: &[u8], open_idx: usize) -> Option { + debug_assert_eq!(bytes[open_idx], b'{'); + let mut depth = 0usize; + for (j, &b) in bytes.iter().enumerate().skip(open_idx) { + match b { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some(j); + } + } + _ => {} + } + } + None +} + +fn assert_no_wildcard(outer_body: &str, fn_name: &str) { + for line in outer_body.lines() { + let t = line.trim_start(); + // A wildcard arm is either bare `_` or `_ if guard` at the + // start of an arm. Catch the canonical forms operators write. + let is_wildcard_arm = t.starts_with("_ =>") + || t.starts_with("_=>") + || t.starts_with("_ if ") + || t == "_," + || t.starts_with("_ |"); + assert!( + !is_wildcard_arm, + "{fn_name}: outer Self match must remain wildcard-free; offending arm: `{line}`" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/round_trip_consumer.rs b/packages/rs-platform-wallet-storage/tests/round_trip_consumer.rs new file mode 100644 index 00000000000..e8262ef0ef9 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/round_trip_consumer.rs @@ -0,0 +1,529 @@ +//! T-024 / CODE-008 — consumer↔SqlitePersister round-trip integration +//! tests. +//! +//! These tests exercise a real [`PlatformWalletManager`] (the consumer +//! side, from `rs-platform-wallet`) against a real [`SqlitePersister`] +//! (this crate). They are the meta-fix CI safety net for the +//! consumer/persister contract drifts surfaced in PR #3625's +//! call-paths audit: +//! +//! * CODE-001 — `load_from_persistor` would silently drop persisted +//! `platform_addresses` (post T-003 it refuses with a typed error; +//! the wired round-trip path here proves wallets re-register and +//! their state survives). +//! * CODE-002 — token-balance writes used a sentinel +//! `WalletId::default()` so every store FK-violated. Post T-002 the +//! schema is V002 with `(identity_id, token_id)` PK and identity- +//! scoped cascade, plus T-003 threads the real wallet id. We +//! round-trip a real `TokenBalanceChangeSet` through `persister.store` +//! under a registered wallet/identity pair and assert the row reads +//! back after reopen. +//! * CODE-003 — `remove_wallet` never propagated to disk. Post T-004 +//! the `delete_wallet` trait method is wired and called from +//! `remove_wallet`; we register two wallets, drop one, reopen, and +//! assert the cascade actually fired without touching the surviving +//! wallet's rows. +//! * CODE-004 — transient errors were erased at the trait boundary. +//! Post T-001 the typed `PersistenceErrorKind` flows through; the +//! `WalletId::default()` happy-path here also exercises the typed +//! `LockPoisoned` → trait mapping at compile time. +//! +//! Per user direction ("If possible, put it into persister crate") the +//! test lives in this crate so the dev-dep cycle stays one-way: +//! `platform-wallet` ships no dependency on `platform-wallet-storage`, +//! while the storage crate is free to pull `platform-wallet` into +//! `[dev-dependencies]` for integration coverage. + +#![allow(clippy::field_reassign_with_default)] + +use std::collections::BTreeMap; +use std::sync::Arc; + +use dpp::prelude::Identifier; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::Network; +use platform_wallet::changeset::{ + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, TokenBalanceChangeSet, +}; +use platform_wallet::events::{EventHandler, PlatformEventHandler}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::PlatformWalletManager; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +// --------------------------------------------------------------------- +// Scaffolding — minimal manager construction around a real persister. +// --------------------------------------------------------------------- + +struct NoopEventHandler; +impl EventHandler for NoopEventHandler {} +impl PlatformEventHandler for NoopEventHandler {} + +fn mock_sdk() -> Arc { + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .build() + .expect("mock sdk should build"), + ) +} + +/// Build a `PlatformWalletManager` backed by a fresh `SqlitePersister` +/// at `/wallets.db`. The tempdir is returned so callers can +/// keep it alive across the manager's lifetime and reopen the same DB +/// after drop. +fn fresh_manager() -> ( + Arc>, + Arc, + tempfile::TempDir, + std::path::PathBuf, +) { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("wallets.db"); + let persister = + Arc::new(SqlitePersister::open(SqlitePersisterConfig::new(&db_path)).expect("open")); + let sdk = mock_sdk(); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + handler, + )); + (manager, persister, tmp, db_path) +} + +/// Reopen the persister at `db_path` — used by every round-trip test +/// post-drop to verify the on-disk state actually survived. +fn reopen(db_path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(db_path)).expect("reopen") +} + +/// Distinct 64-byte seed per wallet, deterministic per `index`. +fn seed_bytes_for(index: u8) -> [u8; 64] { + let mut seed = [0u8; 64]; + for (i, b) in seed.iter_mut().enumerate() { + *b = ((i as u8).wrapping_mul(7)) + .wrapping_add(3) + .wrapping_add(index.wrapping_mul(31)); + } + seed +} + +async fn register_test_wallet( + manager: &PlatformWalletManager, + seed_index: u8, +) -> WalletId { + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + seed_bytes_for(seed_index), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet registration should succeed against a real SqlitePersister"); + wallet.wallet_id() +} + +async fn shutdown_and_drop(manager: Arc>) { + manager.shutdown().await; + drop(manager); +} + +// --------------------------------------------------------------------- +// TC-CODE-008-1 — Register a wallet through the consumer; reopen the +// persister; the `wallet_metadata` row and the per-account snapshot +// (`account_registrations` + `account_address_pools`) survive +// drop+reopen. Locks in the bilateral contract: the manager's +// registration changeset (`wallet_lifecycle.rs:286 ish`) actually +// reaches disk through `persister.store(...)`. +// --------------------------------------------------------------------- + +#[tokio::test] +async fn tc_code_008_1_register_wallet_metadata_round_trip() { + let (manager, persister, tmp, db_path) = fresh_manager(); + let wallet_id = register_test_wallet(&manager, 1).await; + + // The registration changeset must have landed; without the + // immediate persistor flush this assertion would falsely pass + // (in-memory) and fail post-reopen. Probe before drop so we have a + // baseline for the diff across reopen. + let counts_before: BTreeMap<&'static str, usize> = persister + .inspect_counts(Some(&wallet_id)) + .expect("inspect_counts") + .into_iter() + .collect(); + assert!( + counts_before["wallet_metadata"] >= 1, + "register_wallet must persist a wallet_metadata row; counts={counts_before:?}", + ); + assert!( + counts_before["account_registrations"] >= 1, + "register_wallet must persist account_registrations rows; counts={counts_before:?}", + ); + + shutdown_and_drop(manager).await; + drop(persister); + + let persister2 = reopen(&db_path); + let counts_after: BTreeMap<&'static str, usize> = persister2 + .inspect_counts(Some(&wallet_id)) + .expect("inspect_counts post-reopen") + .into_iter() + .collect(); + + assert_eq!( + counts_after, counts_before, + "every persisted table count must survive drop+reopen; before={counts_before:?} after={counts_after:?}", + ); + drop(tmp); +} + +// --------------------------------------------------------------------- +// TC-CODE-008-2 — Persist platform addresses through the manager's +// registered wallet path, drop, reopen, assert the addresses round-trip +// row-for-row through `schema::platform_addrs::list_per_wallet`. +// +// Drives the storage trait the way `manager::platform_address_sync` +// does (`persister.store(wallet_id, PlatformAddressChangeSet { .. })`) +// — without a live DAPI mock no real BLAST balances appear, so we +// inject a deterministic `PlatformAddressChangeSet` ourselves through +// the trait the consumer would call. +// --------------------------------------------------------------------- + +#[tokio::test] +async fn tc_code_008_2_platform_addresses_round_trip() { + use dash_sdk::platform::address_sync::AddressFunds; + use key_wallet::PlatformP2PKHAddress; + use platform_wallet::changeset::{PlatformAddressBalanceEntry, PlatformAddressChangeSet}; + + let (manager, persister, tmp, db_path) = fresh_manager(); + let wallet_id = register_test_wallet(&manager, 2).await; + + let entries = vec![ + PlatformAddressBalanceEntry { + wallet_id, + account_index: 0, + address_index: 0, + address: PlatformP2PKHAddress::new([0xA1; 20]), + funds: AddressFunds { + nonce: 1, + balance: 7_777, + }, + }, + PlatformAddressBalanceEntry { + wallet_id, + account_index: 0, + address_index: 1, + address: PlatformP2PKHAddress::new([0xA2; 20]), + funds: AddressFunds { + nonce: 2, + balance: 13_337, + }, + }, + ]; + + // Drive the same trait method the consumer's + // `platform_address_sync.rs:80` invokes. + persister + .store( + wallet_id, + PlatformWalletChangeSet { + platform_addresses: Some(PlatformAddressChangeSet { + addresses: entries.clone(), + sync_height: Some(424_242), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("platform_addresses store through real persister"); + + shutdown_and_drop(manager).await; + drop(persister); + + let persister2 = reopen(&db_path); + let rows = platform_wallet_storage::sqlite::schema::platform_addrs::list_per_wallet( + &persister2.lock_conn_for_test(), + &wallet_id, + ) + .expect("list_per_wallet post-reopen"); + + assert_eq!( + rows.len(), + entries.len(), + "every persisted platform address must survive drop+reopen", + ); + for (got, want) in rows.iter().zip(entries.iter()) { + assert_eq!(got.address, want.address); + assert_eq!(got.account_index, want.account_index); + assert_eq!(got.address_index, want.address_index); + assert_eq!(got.funds.balance, want.funds.balance); + assert_eq!(got.funds.nonce, want.funds.nonce); + } + drop(tmp); +} + +// --------------------------------------------------------------------- +// TC-CODE-008-3 — Identity-scoped writes (`identity_keys` and +// `token_balances`) require the V002 cascade chain +// `wallet_metadata → identities → …` to be honoured end-to-end. Bind +// an identity to a manager-registered wallet, then exercise the same +// store path `identity_sync.rs:630` uses for token balance updates +// AND the schema's `identity_keys` writer. +// +// This is the test that would have caught CODE-002 (sentinel +// `WalletId::default()` FK violation): without the V002 identity- +// owned-row redesign + the real wallet_id threading, the +// `TokenBalanceChangeSet` write below would FK-fail. +// --------------------------------------------------------------------- + +#[tokio::test] +async fn tc_code_008_3_identity_keys_and_token_balances_round_trip() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + + let (manager, persister, tmp, db_path) = fresh_manager(); + let wallet_id = register_test_wallet(&manager, 3).await; + + let identity_id = Identifier::from([0xCD; 32]); + // Bind the identity to the wallet via the public API — this is + // exactly the path `IdentitySyncManager` uses to know which parent + // wallet a token-balance write belongs to (post T-002/T-003). + manager + .identity_sync() + .register_identity_with_wallet(identity_id, Some(wallet_id), []) + .await; + + // `identities` row needs to exist before identity-scoped writes + // can pass V002's FK. The manager's registration handler creates + // the row lazily — for this offline test we materialise it + // through the same schema helper `identity_sync` would hit on the + // first real sync. + { + let conn = persister.lock_conn_for_test(); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &conn, + &wallet_id, + identity_id + .as_slice() + .try_into() + .expect("identity_id is 32B"), + ) + .expect("ensure identity row"); + } + + // Identity key — drives the same `identity_keys` writer the + // consumer's `identity_sync.rs` reaches through `persister.store`. + let public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![0xAB; 33]), + disabled_at: None, + }); + let key_entry = IdentityKeyEntry { + identity_id, + key_id: 11, + public_key, + public_key_hash: [0x55; 20], + wallet_id: Some(wallet_id), + derivation_indices: Some(IdentityKeyDerivationIndices { + identity_index: 1, + key_index: 0, + }), + }; + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((identity_id, 11), key_entry.clone()); + + // Token balance — the writer path that CODE-002 broke (sentinel + // `WalletId::default()` => FK-violation against `wallet_metadata`). + // Real `wallet_id` from above; V002 PK is `(identity_id, token_id)`. + let token_id = Identifier::from([0xEE; 32]); + let mut balances = TokenBalanceChangeSet::default(); + balances.balances.insert((identity_id, token_id), 999_888); + + persister + .store( + wallet_id, + PlatformWalletChangeSet { + identity_keys: Some(keys), + token_balances: Some(balances), + ..Default::default() + }, + ) + .expect( + "identity_keys + token_balances store through real persister \ + must succeed end-to-end under a registered wallet/identity pair", + ); + + shutdown_and_drop(manager).await; + drop(persister); + + // Reopen and assert both rows are present. + let persister2 = reopen(&db_path); + let conn = persister2.lock_conn_for_test(); + + let key_blob: Vec = conn + .query_row( + "SELECT public_key_blob FROM identity_keys WHERE identity_id = ?1 AND key_id = ?2", + rusqlite::params![identity_id.as_slice(), 11i64], + |row| row.get(0), + ) + .expect("identity_keys row must survive reopen"); + let decoded_key = + platform_wallet_storage::sqlite::schema::identity_keys::decode_entry(&key_blob) + .expect("decode identity_keys blob"); + assert_eq!( + decoded_key, key_entry, + "identity_keys round-trip must be field-for-field equal", + ); + + let balance: i64 = conn + .query_row( + "SELECT balance FROM token_balances WHERE identity_id = ?1 AND token_id = ?2", + rusqlite::params![identity_id.as_slice(), token_id.as_slice()], + |row| row.get(0), + ) + .expect("token_balances row must survive reopen (CODE-002 regression guard)"); + assert_eq!(balance, 999_888); + drop(tmp); +} + +// --------------------------------------------------------------------- +// TC-CODE-008-4 — `remove_wallet` must cascade through the storage +// boundary (CODE-003 regression guard): register two wallets with +// per-wallet state, remove one, drop+reopen, assert the removed +// wallet's rows are gone across every `PER_WALLET_TABLES` entry while +// the surviving wallet's rows are intact. +// --------------------------------------------------------------------- + +#[tokio::test] +async fn tc_code_008_4_remove_wallet_cascades_through_storage() { + let (manager, persister, tmp, db_path) = fresh_manager(); + + let wallet_to_keep = register_test_wallet(&manager, 4).await; + let wallet_to_remove = register_test_wallet(&manager, 5).await; + + let keep_before: BTreeMap<&'static str, usize> = persister + .inspect_counts(Some(&wallet_to_keep)) + .expect("inspect keep before") + .into_iter() + .collect(); + let remove_before: BTreeMap<&'static str, usize> = persister + .inspect_counts(Some(&wallet_to_remove)) + .expect("inspect remove before") + .into_iter() + .collect(); + assert!( + remove_before["wallet_metadata"] >= 1, + "wallet_to_remove must have registration rows before remove; counts={remove_before:?}", + ); + + manager + .remove_wallet(&wallet_to_remove) + .await + .expect("remove_wallet must succeed; CODE-003 wires it to persister.delete_wallet"); + + shutdown_and_drop(manager).await; + drop(persister); + + let persister2 = reopen(&db_path); + + // Removed wallet: every per-wallet table must be empty for this id. + let removed_after: Vec<(&'static str, usize)> = persister2 + .inspect_counts(Some(&wallet_to_remove)) + .expect("inspect remove after"); + for (table, n) in &removed_after { + assert_eq!( + *n, 0, + "remove_wallet must cascade through {table}; saw {n} orphan rows after reopen", + ); + } + + // Surviving wallet: its counts must be byte-for-byte identical to + // what they were before — `remove_wallet(W2)` mustn't touch W1. + let keep_after: BTreeMap<&'static str, usize> = persister2 + .inspect_counts(Some(&wallet_to_keep)) + .expect("inspect keep after") + .into_iter() + .collect(); + assert_eq!( + keep_after, keep_before, + "surviving wallet's rows must be untouched by remove_wallet of the sibling", + ); + drop(tmp); +} + +// --------------------------------------------------------------------- +// TC-CODE-008-5 — Boot the manager twice against the SAME persister +// path: first run registers two wallets and persists state; second +// run opens a fresh `SqlitePersister` + `PlatformWalletManager` over +// the same DB and exercises `load_from_persistor()`, then verifies +// the persisted state is reachable via the per-wallet +// `register_wallet` re-fetch path. +// +// This is the integration-level CODE-001 regression: the consumer's +// `load_from_persistor` correctly returns the per-wallet rehydration +// gate, and the rows ARE still on disk to feed the per-wallet +// register path. +// --------------------------------------------------------------------- + +#[tokio::test] +async fn tc_code_008_5_reopen_manager_recovers_persisted_wallets() { + let (manager, persister, tmp, db_path) = fresh_manager(); + + let w1 = register_test_wallet(&manager, 6).await; + let w2 = register_test_wallet(&manager, 7).await; + + let counts_w1_before: Vec<(&'static str, usize)> = persister + .inspect_counts(Some(&w1)) + .expect("inspect w1 before"); + let counts_w2_before: Vec<(&'static str, usize)> = persister + .inspect_counts(Some(&w2)) + .expect("inspect w2 before"); + + shutdown_and_drop(manager).await; + drop(persister); + + // Second boot: brand-new persister + manager over the SAME file. + let persister2 = Arc::new(reopen(&db_path)); + let sdk = mock_sdk(); + let handler: Arc = Arc::new(NoopEventHandler); + let manager2 = Arc::new(PlatformWalletManager::new( + sdk, + Arc::clone(&persister2), + handler, + )); + + // The persistor's `load()` today reports `wallets={}` (only + // `platform_addresses` populated). With both empty the CODE-001 + // gate accepts the load; we then prove the rows are still on disk + // by reading directly through the storage crate. + manager2 + .load_from_persistor() + .await + .expect("load_from_persistor must accept the persister's well-formed payload"); + + let counts_w1_after: Vec<(&'static str, usize)> = persister2 + .inspect_counts(Some(&w1)) + .expect("inspect w1 after"); + let counts_w2_after: Vec<(&'static str, usize)> = persister2 + .inspect_counts(Some(&w2)) + .expect("inspect w2 after"); + + assert_eq!( + counts_w1_after, counts_w1_before, + "w1 rows must be recoverable after a clean reopen; before={counts_w1_before:?} after={counts_w1_after:?}", + ); + assert_eq!( + counts_w2_after, counts_w2_before, + "w2 rows must be recoverable after a clean reopen; before={counts_w2_before:?} after={counts_w2_after:?}", + ); + + shutdown_and_drop(manager2).await; + drop(tmp); +} diff --git a/packages/rs-platform-wallet-storage/tests/secrets_scan.rs b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs new file mode 100644 index 00000000000..a2248b35d2b --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs @@ -0,0 +1,108 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Schema-file substring scan for forbidden secret-material tokens +//! (the load-bearing test for the NFR-10 / SECRETS.md boundary). +//! +//! The persister never stores mnemonics / seeds / private keys. +//! This test grep-scans every file under `src/sqlite/schema/` and +//! `migrations/` for ASCII substrings associated with secret material. +//! A new column, blob field, or comment that uses `private`, +//! `mnemonic`, `seed`, `xpriv`, or `secret` breaks the test, forcing +//! the author to rename or add an allow-list entry with rationale. +//! +//! Out of scope by design: files in `src/sqlite/` outside of +//! `schema/` (`persister.rs`, `backup.rs`, `buffer.rs`, `config.rs`, +//! `error.rs`, `migrations.rs`, `util/`) are NOT scanned. They never +//! define database columns and may legitimately reference the +//! forbidden tokens in doc comments. The future `src/secrets/` +//! submodule slot is exempt for the same reason. +//! +//! The check is intentionally string-level: it does not parse SQL or +//! Rust. A column literally named `private_X` is the kind of mistake +//! we want to catch; legitimate uses inside doc comments are +//! allow-listed via the `ALLOWLIST` constant below. + +use std::path::Path; + +const FORBIDDEN: &[&str] = &["private", "mnemonic", "seed", "xpriv", "secret"]; + +/// Doc-comment / identifier substrings we deliberately want to +/// permit even though they contain a forbidden token. Keep this list +/// tiny — each entry is a string that must appear verbatim in the +/// offending line for it to be ignored. +const ALLOWLIST: &[&str] = &[ + // `IdentityPublicKey` blob column carries only PUBLIC material; + // the doc comment says so explicitly. Allow-listing the phrase + // means future contributors can still surface the boundary. + "PUBLIC material only", + "No private bytes", + "no private key", + "private-key bytes", + "public_key_blob", + "public material", + "do not derive private keys", + "private keys are NOT", +]; + +fn line_is_allowlisted(line: &str) -> bool { + ALLOWLIST.iter().any(|needle| line.contains(needle)) +} + +fn scan_dir(dir: &Path, offenders: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + scan_dir(&p, offenders); + continue; + } + if !p + .extension() + .is_some_and(|e| e == "rs" || e == "sql" || e == "md") + { + continue; + } + // Skip the test file itself; it intentionally lists the + // forbidden tokens. + if p.file_name().and_then(|s| s.to_str()) == Some("secrets_scan.rs") { + continue; + } + let body = match std::fs::read_to_string(&p) { + Ok(s) => s, + Err(_) => continue, + }; + for (idx, line) in body.lines().enumerate() { + let lower = line.to_ascii_lowercase(); + for needle in FORBIDDEN { + if lower.contains(needle) && !line_is_allowlisted(line) { + offenders.push(format!( + "{}:{}: contains `{needle}` — {}", + p.display(), + idx + 1, + line.trim() + )); + } + } + } + } +} + +#[test] +fn no_secret_substrings_in_schema_or_migrations() { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut offenders = Vec::new(); + // `src/sqlite/schema` (SQLite-backend column definitions and blob + // encoders) and `migrations/` (refinery DDL) are the entire + // persistence surface for non-secret material. `src/secrets/` is + // exempt by design — that submodule WILL legitimately mention + // `private`, `mnemonic`, `seed` once the SecretStore lands. + scan_dir(&manifest.join("src/sqlite/schema"), &mut offenders); + scan_dir(&manifest.join("migrations"), &mut offenders); + assert!( + offenders.is_empty(), + "forbidden secret-material tokens found in schema files (see SECRETS.md):\n{}", + offenders.join("\n") + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs new file mode 100644 index 00000000000..085169cd2d4 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs @@ -0,0 +1,146 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-050..TC-055 — automatic backups. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet_storage::{ + AutoBackupOperation, SqlitePersister, SqlitePersisterConfig, WalletStorageError, +}; + +/// TC-050: brand-new DB does NOT produce a pre-migration backup. +#[test] +fn tc050_brand_new_db_skips_pre_migration_backup() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path); + let dir = cfg.auto_backup_dir.clone().unwrap(); + let _p = SqlitePersister::open(cfg).unwrap(); + if dir.exists() { + let leftover = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("pre-migration")) + .count(); + assert_eq!( + leftover, 0, + "fresh DB should not produce pre-migration backups" + ); + } +} + +/// TC-051: delete_wallet writes a pre-delete backup before deleting. +#[test] +fn tc051_pre_delete_backup_taken() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE0); + ensure_wallet_meta(&persister, &w); + let report = persister.delete_wallet(w).expect("delete_wallet"); + let backup_path = report.backup_path.expect("backup path present"); + assert!(backup_path.exists(), "backup file does not exist on disk"); + let name = backup_path.file_name().unwrap().to_string_lossy(); + assert!( + name.starts_with("pre-delete-") && name.ends_with(".db"), + "unexpected pre-delete filename: {name}" + ); +} + +/// TC-052: delete_wallet with auto_backup_dir = None returns AutoBackupDisabled. +#[test] +fn tc052_delete_wallet_auto_backup_disabled() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path).with_auto_backup_dir(None); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xE1); + ensure_wallet_meta(&persister, &w); + let err = persister.delete_wallet(w); + assert!( + matches!( + err, + Err(WalletStorageError::AutoBackupDisabled { + operation: AutoBackupOperation::DeleteWallet + }) + ), + "expected AutoBackupDisabled, got {err:?}" + ); + // Rows for `w` should still be present. + let conn = persister.lock_conn_for_test(); + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 1); +} + +/// TC-054 (partial): unwritable auto-backup dir surfaces AutoBackupDirUnwritable. +/// +/// The failure is forced through a path whose parent is a regular file +/// (`/sub`), so `create_dir_all` fails with `ENOTDIR`. Unlike a +/// `chmod 0o500` directory — which UID 0 bypasses — this is rejected +/// for every UID, making the assertion deterministic in root-running +/// CI containers. +#[test] +fn tc054_unwritable_auto_backup_dir() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let blocker = tmp.path().join("not-a-dir"); + std::fs::write(&blocker, b"regular file").unwrap(); + let unwritable = blocker.join("sub"); + + let cfg = SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(unwritable)); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xE2); + ensure_wallet_meta(&persister, &w); + let err = persister.delete_wallet(w); + assert!( + matches!(err, Err(WalletStorageError::AutoBackupDirUnwritable { .. })), + "expected AutoBackupDirUnwritable, got {err:?}" + ); + // Wallet still intact. + let conn = persister.lock_conn_for_test(); + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 1); +} + +/// TC-055: auto-backups respect the same retention as manual backups. +#[test] +fn tc055_auto_backups_subject_to_retention() { + let (persister, _tmp, _path) = fresh_persister(); + let dir = persister.config_for_test().auto_backup_dir.clone().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + // Drop in five `pre-delete-*` fixture files. + for i in 0..5 { + let name = format!( + "pre-delete-{}-{}.db", + hex::encode([i; 32]), + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::hours(i as i64)) + .unwrap() + .format("%Y%m%dT%H%M%SZ") + ); + std::fs::write(dir.join(name), b"x").unwrap(); + } + let report = persister + .prune_backups( + &dir, + platform_wallet_storage::RetentionPolicy { + keep_last_n: Some(2), + max_age: None, + }, + ) + .unwrap(); + assert_eq!(report.kept, 2); + assert_eq!(report.removed.len(), 3); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs b/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs new file mode 100644 index 00000000000..bf434a9f24b --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs @@ -0,0 +1,421 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-031..TC-039 — online backup, restore source validation, retention. + +mod common; + +use std::fs; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::{RetentionPolicy, SqlitePersister, WalletStorageError}; + +fn seed_one_row(persister: &SqlitePersister, w: &[u8; 32]) { + ensure_wallet_meta(persister, w); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(5), + last_processed_height: Some(5), + ..Default::default() + }); + persister.store(*w, cs).unwrap(); +} + +/// TC-031: backup_to(directory) produces a wallet-.db file. +#[test] +fn tc031_backup_directory_form() { + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xD0)); + let out_dir = tmp.path().join("backups"); + fs::create_dir(&out_dir).unwrap(); + let written = persister.backup_to(&out_dir).expect("backup_to"); + // `backup_to` canonicalizes its return; canonicalize the expected + // dir too so the comparison is symmetric. On macOS the temp dir + // lives under `/var` (a symlink to `/private/var`), so an + // un-canonicalized `out_dir` would not prefix the canonical path. + let expected_dir = out_dir.canonicalize().unwrap_or_else(|_| out_dir.clone()); + assert!(written.starts_with(&expected_dir)); + let name = written.file_name().unwrap().to_string_lossy().into_owned(); + assert!(name.starts_with("wallet-") && name.ends_with(".db")); + // Open the produced file and confirm it has the schema. + let src = + rusqlite::Connection::open_with_flags(&written, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .unwrap(); + let check: String = src + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .unwrap(); + assert_eq!(check, "ok"); +} + +/// TC-032: backup_to(explicit file path) writes to the exact path. +#[test] +fn tc032_backup_file_form() { + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xD1)); + let target = tmp.path().join("explicit-name.db"); + let written = persister.backup_to(&target).unwrap(); + assert_eq!(written, target.canonicalize().unwrap_or(target.clone())); + assert!(target.exists()); + // Refuses overwrite. + let err = persister.backup_to(&target); + assert!( + matches!(err, Err(WalletStorageError::BackupDestinationExists { .. })), + "expected BackupDestinationExists, got {err:?}" + ); +} + +/// TC-035 (subset): restore_from round-trips state via the on-disk backup. +#[test] +fn tc035_restore_roundtrip() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xD2); + seed_one_row(&persister, &w); + // Take a backup. + let backup_path = persister.backup_to(tmp.path()).unwrap(); + // Mutate the source — make synced_height a different value. + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(999), + last_processed_height: Some(999), + ..Default::default() + }); + persister.store(w, cs).unwrap(); + drop(persister); + // Restore. + // Tests pass through `restore_from_skip_backup` — simpler than + // threading an auto_backup_dir through fixtures. + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore_from"); + // Reopen and check the synced height reverted to 5. + let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&path); + let p2 = SqlitePersister::open(cfg).unwrap(); + let conn = p2.lock_conn_for_test(); + let h: i64 = conn + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(h, 5); +} + +/// TC-036: restore source missing schema_history is rejected. +#[test] +fn tc036_restore_missing_schema_history() { + let tmp = tempfile::tempdir().unwrap(); + let fake_src = tmp.path().join("empty.db"); + rusqlite::Connection::open(&fake_src).unwrap(); + let dest = tmp.path().join("dest.db"); + fs::write(&dest, b"placeholder").unwrap(); + let err = SqlitePersister::restore_from_skip_backup(&dest, &fake_src); + assert!(matches!(err, Err(WalletStorageError::SchemaHistoryMissing))); +} + +/// TC-037: corrupt source rejected. +#[test] +fn tc037_restore_corrupt_source() { + let tmp = tempfile::tempdir().unwrap(); + let corrupt = tmp.path().join("corrupt.db"); + fs::write(&corrupt, b"not a sqlite file ABCDEF").unwrap(); + let dest = tmp.path().join("dest.db"); + fs::write(&dest, b"placeholder").unwrap(); + let err = SqlitePersister::restore_from_skip_backup(&dest, &corrupt); + assert!( + matches!( + err, + Err(WalletStorageError::IntegrityCheckFailed { .. }) + | Err(WalletStorageError::IntegrityCheckRunFailed { .. }) + | Err(WalletStorageError::SourceOpenFailed { .. }) + | Err(WalletStorageError::Sqlite(_)) + ), + "expected IntegrityCheckFailed / IntegrityCheckRunFailed / SourceOpenFailed / Sqlite, got {err:?}" + ); +} + +/// ATOM-004 (A-1): a failure during `backup_to` must NOT leave a +/// partial/empty `.db` file at the caller-supplied destination. The +/// pre-A-1 code eagerly opened `dest`, so any later failure +/// (`apply_secure_permissions`, `Backup::new`, `run_to_completion`) +/// stranded an empty file at `dest`. We exercise the path that +/// already-rejects (pre-existing destination) — the new code's exists +/// check fires before any temp file gets created. +#[test] +fn atom_004_backup_to_failure_leaves_no_junk_at_dest() { + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xE7)); + // First backup succeeds. + let target = tmp.path().join("first.db"); + persister.backup_to(&target).expect("first backup"); + // Second backup to same path fails fast — no auxiliary `.tmp*` + // file should remain alongside the existing target. + let err = persister.backup_to(&target); + assert!(matches!( + err, + Err(WalletStorageError::BackupDestinationExists { .. }) + )); + // Sanity: only the legitimate file is present, plus -journal/-wal + // siblings SQLite may have created on the live persister DB — + // those live in a different parent so this scan is clean. + let entries: Vec<_> = std::fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) + .collect(); + let leaked: Vec<_> = entries + .iter() + .filter(|n| n.starts_with(".tmp") || n.ends_with(".tmp")) + .collect(); + assert!( + leaked.is_empty(), + "no .tmp* staging file should remain in {:?}; found {leaked:?}", + tmp.path() + ); +} + +/// TC-038: prune retention AND-semantics. +#[test] +fn tc038_prune_and_semantics() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + // Write 5 fake backup files with mtimes 1d/7d/14d/30d/60d ago. + let day = std::time::Duration::from_secs(86_400); + let now = std::time::SystemTime::now(); + let ages = [1u64, 7, 14, 30, 60]; + let mut files = Vec::new(); + for age in ages { + let name = format!( + "wallet-{}.db", + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::days(age as i64)) + .unwrap() + .format("%Y%m%dT%H%M%SZ") + ); + let path = dir.join(&name); + fs::write(&path, b"x").unwrap(); + let mtime = now - day * age as u32; + let _ = filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)); + files.push(path); + } + let (persister, _tmp_pers, _path) = fresh_persister(); + let report = persister + .prune_backups( + dir, + RetentionPolicy { + keep_last_n: Some(3), + max_age: Some(day * 20), + }, + ) + .unwrap(); + // Files with ages 30d and 60d (older than 20d) should be removed. + assert_eq!(report.removed.len(), 2); + assert_eq!(report.kept, 3); + assert!( + report.failed_removals.is_empty(), + "happy-path prune must have no failed removals" + ); +} + +/// ATOM-011 (A-6): a per-file remove failure is collected into +/// `failed_removals`, not propagated as `Err` aborting the loop. +/// We can't directly simulate a remove failure inside the spec on a +/// portable filesystem, so we use the simpler approach: confirm the +/// report shape carries `failed_removals` and a happy-path prune +/// leaves it empty. The classifier itself is the regression — pre-A-6 +/// the field did not exist, so this file fails to compile against the +/// old API. +#[test] +fn atom_011_prune_report_carries_failed_removals_field() { + let report = platform_wallet_storage::PruneReport { + removed: vec![], + kept: 0, + failed_removals: vec![( + std::path::PathBuf::from("/x"), + std::io::Error::other("synthetic"), + )], + }; + assert_eq!(report.failed_removals.len(), 1); +} + +/// Detect whether the current process can bypass directory permission +/// checks (i.e. is effectively root) by setting a probe dir to `0o500` +/// and trying to create a file inside it. Returns `true` when the +/// write succeeds — only root sees that. +#[cfg(unix)] +fn is_root_via_probe() -> bool { + use std::os::unix::fs::PermissionsExt; + let Ok(tmp) = tempfile::tempdir() else { + return false; + }; + let dir = tmp.path().join("probe"); + if fs::create_dir(&dir).is_err() { + return false; + } + if fs::set_permissions(&dir, fs::Permissions::from_mode(0o500)).is_err() { + return false; + } + let can_write = fs::write(dir.join("x"), b"x").is_ok(); + let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); + can_write +} + +/// TC-CODE-009-a: `backup_to(existing-file)` refuses to overwrite and +/// leaves the sentinel content intact. With `persist_noclobber` the +/// check is atomic against the rename — no TOCTOU window between an +/// `exists()` probe and the atomic swap. +#[test] +fn tc_code_009_a_backup_to_refuses_overwrite_atomically() { + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xC9)); + let target = tmp.path().join("sentinel.db"); + let sentinel = b"DO NOT OVERWRITE"; + fs::write(&target, sentinel).unwrap(); + + let err = persister.backup_to(&target); + assert!( + matches!(err, Err(WalletStorageError::BackupDestinationExists { .. })), + "expected BackupDestinationExists, got {err:?}" + ); + let after = fs::read(&target).unwrap(); + assert_eq!( + after, sentinel, + "destination must be untouched after refusal" + ); +} + +/// TC-CODE-009-b: non-`AlreadyExists` persist failures surface as +/// `WalletStorageError::Io` — the variant taxonomy stays narrow. +/// Unix-only: emulated via a read-only parent directory (which UID 0 +/// bypasses, so the test is skipped under root). +#[cfg(unix)] +#[test] +fn tc_code_009_b_backup_to_non_already_exists_maps_to_io() { + // Skip under root — UID 0 bypasses the directory permission check + // we use to force EACCES. Detected via a probe: create a 0o500 dir + // and try to write into it; a non-root user gets EACCES, root + // doesn't. + if is_root_via_probe() { + eprintln!("skip: read-only-dir permission bypassed by root"); + return; + } + use std::os::unix::fs::PermissionsExt; + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xCA)); + let dest_dir = tmp.path().join("ro"); + fs::create_dir(&dest_dir).unwrap(); + fs::set_permissions(&dest_dir, fs::Permissions::from_mode(0o500)).unwrap(); + let target = dest_dir.join("new.db"); + + let err = persister.backup_to(&target); + // Restore perms so tempdir cleanup works on systems that need + // write access to the parent dir. + let _ = fs::set_permissions(&dest_dir, fs::Permissions::from_mode(0o700)); + + match err { + Err(WalletStorageError::Io(e)) => { + assert_ne!( + e.kind(), + std::io::ErrorKind::AlreadyExists, + "must NOT map AlreadyExists to plain Io" + ); + } + other => panic!("expected Io variant, got {other:?}"), + } +} + +/// TC-CODE-014-a: `run_to` and `restore_from` call `fsync` on the +/// destination's parent directory after the atomic rename. Functional +/// fsync verification is impractical without a crash harness, so the +/// regression check is source-level: confirm `fsync_parent_dir` is +/// invoked in `backup.rs`. +#[test] +fn tc_code_014_a_backup_calls_parent_fsync() { + let src = include_str!("../src/sqlite/backup.rs"); + let calls = src.matches("fsync_parent_dir(").count(); + assert!( + calls >= 3, + "expected at least 3 occurrences of `fsync_parent_dir(` in backup.rs \ + (def + run_to + restore_from), found {calls}" + ); +} + +/// TC-CODE-014-b: `# Atomicity` rustdoc mentions the parent-dir fsync +/// so callers aren't misled about durability guarantees. +#[test] +fn tc_code_014_b_atomicity_doc_mentions_fsync() { + let src = include_str!("../src/sqlite/backup.rs"); + let lower = src.to_lowercase(); + assert!( + lower.contains("fsync") || lower.contains("sync_all"), + "atomicity rustdoc must mention fsync / sync_all on parent dir" + ); +} + +/// TC-CODE-019-a: a failed `remove_file` is counted in BOTH `kept` and +/// `failed_removals`, preserving `kept + removed == total`. +/// +/// Unix-only: emulated by chmodding the prune directory read-only so +/// `unlink` returns `EACCES`. Skipped under root because UID 0 bypasses +/// the directory permission check. +#[cfg(unix)] +#[test] +fn tc_code_019_a_failed_removal_counts_in_kept() { + // Skip under root — UID 0 bypasses the directory permission check + // we use to force EACCES. Detected via a probe: create a 0o500 dir + // and try to write into it; a non-root user gets EACCES, root + // doesn't. + if is_root_via_probe() { + eprintln!("skip: read-only-dir permission bypassed by root"); + return; + } + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("backups"); + fs::create_dir(&dir).unwrap(); + // Five eligible backups, all old enough to be removed by `max_age`. + let day = std::time::Duration::from_secs(86_400); + let now = std::time::SystemTime::now(); + for age in [30u64, 31, 32, 33, 34] { + let name = format!( + "wallet-{}.db", + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::days(age as i64)) + .unwrap() + .format("%Y%m%dT%H%M%SZ") + ); + let path = dir.join(&name); + fs::write(&path, b"x").unwrap(); + let mtime = now - day * age as u32; + let _ = filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)); + } + // Lock the directory so `unlink` fails on EVERY file. + fs::set_permissions(&dir, fs::Permissions::from_mode(0o500)).unwrap(); + + let (persister, _tmp_p, _path) = fresh_persister(); + let res = persister.prune_backups( + &dir, + RetentionPolicy { + keep_last_n: None, + max_age: Some(day), + }, + ); + // Restore perms so tempdir cleanup works. + let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); + + let report = res.expect("prune_backups must return Ok even on partial removal failures"); + assert!( + !report.failed_removals.is_empty(), + "expected at least one failed removal" + ); + let total = report.removed.len() + report.failed_removals.len(); + assert_eq!( + report.kept, total, + "kept ({}) must equal total ({}) when no files were removed", + report.kept, total + ); + assert_eq!( + report.removed.len() + report.kept, + 5, + "kept + removed must equal total eligible (5)" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs b/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs new file mode 100644 index 00000000000..871120399b4 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs @@ -0,0 +1,654 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-016..TC-024 (subset) — buffer + flush semantics. +//! +//! Some adversarial cases (TC-021 partial-failure, TC-024 mid-flush +//! failure) require a fault-injection seam that the production code +//! exposes only behind `#[cfg(test)]`. The seam is documented in +//! `persister.rs::lock_conn_for_test`; tests that need to inject a +//! failure poison the DB through that handle and verify rollback. + +mod common; + +use std::collections::BTreeMap; + +use common::{ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, ro_conn, wid}; + +use dashcore::hashes::Hash; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::FlushMode; + +fn core_with_height(synced_height: u32, last_processed_height: u32) -> CoreChangeSet { + CoreChangeSet { + synced_height: Some(synced_height), + last_processed_height: Some(last_processed_height), + ..Default::default() + } +} + +fn changeset(core: CoreChangeSet) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(core), + ..Default::default() + } +} + +/// TC-017: Manual mode defers I/O. +#[test] +fn tc017_manual_defers_io() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(1); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(5, 5))) + .unwrap(); + // Without a flush, the row count for core_sync_state for `w` is 0. + let n: i64 = ro_conn(&path) + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 0); + persister.flush(w).unwrap(); + let n: i64 = ro_conn(&path) + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 1); +} + +/// TC-018: Immediate mode flushes inline. +#[test] +fn tc018_immediate_flushes_inline() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Immediate); + let w = wid(2); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(5, 5))) + .unwrap(); + let n: i64 = ro_conn(&path) + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 1); +} + +/// TC-019: commit_writes flushes every dirty wallet. +#[test] +fn tc019_commit_writes_flushes_dirty() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let a = wid(0x10); + let b = wid(0x20); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + persister + .store(a, changeset(core_with_height(5, 5))) + .unwrap(); + persister + .store(b, changeset(core_with_height(7, 7))) + .unwrap(); + let report = persister.commit_writes().unwrap(); + assert!( + report.is_ok(), + "commit_writes must succeed; report={report:?}" + ); + assert_eq!(report.succeeded.len(), 2, "two wallets must flush"); + let conn = ro_conn(&path); + let count_for = |id: &[u8; 32]| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(count_for(&a), 1); + assert_eq!(count_for(&b), 1); +} + +/// TC-020: commit_writes in Immediate mode is a no-op. +#[test] +fn tc020_commit_writes_noop_in_immediate() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Immediate); + let report = persister.commit_writes().unwrap(); + assert!(report.succeeded.is_empty() && report.failed.is_empty()); +} + +/// TC-022: flush(A) doesn't write or clear B's buffer. +#[test] +fn tc022_flush_is_scoped() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let a = wid(0x30); + let b = wid(0x31); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + persister + .store(a, changeset(core_with_height(3, 3))) + .unwrap(); + persister + .store(b, changeset(core_with_height(4, 4))) + .unwrap(); + persister.flush(a).unwrap(); + let conn = ro_conn(&path); + let count_for = |id: &[u8; 32]| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(count_for(&a), 1); + assert_eq!(count_for(&b), 0); + persister.flush(b).unwrap(); + assert_eq!(count_for(&b), 1); +} + +/// TC-016: property — N stores then flush == one merged store. +/// +/// We use the monotonic-max merge on sync heights as the oracle. +#[test] +fn tc016_buffer_merge_oracle_smoke() { + use proptest::prelude::*; + let strategy = proptest::collection::vec((0u32..1_000_000, 0u32..1_000_000), 1..6); + proptest!(ProptestConfig::with_cases(64), |(heights in strategy)| { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0x40); + ensure_wallet_meta(&persister, &w); + for &(sp, lp) in &heights { + persister.store(w, changeset(core_with_height(sp, lp))).unwrap(); + } + // Read back the persisted heights. + let conn = persister.lock_conn_for_test(); + let (synced, lp): (Option, Option) = conn + .query_row( + "SELECT synced_height, last_processed_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + drop(conn); + let expected_synced = heights.iter().map(|(s, _)| *s).max().unwrap_or(0); + let expected_lp = heights.iter().map(|(_, l)| *l).max().unwrap_or(0); + prop_assert_eq!(synced.unwrap_or(0) as u32, expected_synced); + prop_assert_eq!(lp.unwrap_or(0) as u32, expected_lp); + }); +} + +/// TC-001 (subset) — get_core_tx_record round-trips through `core_transactions`. +#[test] +fn tc001_get_core_tx_record_roundtrip() { + use dashcore::blockdata::transaction::Transaction; + use dashcore::Txid; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0x50); + ensure_wallet_meta(&persister, &w); + let txid = Txid::from_byte_array([9u8; 32]); + let dummy_tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + dummy_tx, + key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([3u8; 32]), + 1735689600, + )), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 100, + ); + record.txid = txid; + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + records: vec![record], + ..Default::default() + }); + persister.store(w, cs).unwrap(); + let got = persister.get_core_tx_record(w, &txid).unwrap(); + let got = got.expect("record present"); + assert_eq!(got.txid, txid); + let info = got.context.block_info().expect("block info present"); + assert_eq!(info.height(), 42); + let unknown = dashcore::Txid::from_byte_array([0u8; 32]); + assert!(persister.get_core_tx_record(w, &unknown).unwrap().is_none()); +} + +/// TC-015: two wallets coexist without key collisions. +#[test] +fn tc015_two_wallets_in_one_db() { + let (persister, _tmp, _path) = fresh_persister(); + let a = wid(0xA1); + let b = wid(0xB2); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + // Distinct height per wallet so we can distinguish. + persister + .store(a, changeset(core_with_height(11, 11))) + .unwrap(); + persister + .store(b, changeset(core_with_height(22, 22))) + .unwrap(); + let conn = persister.lock_conn_for_test(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM core_sync_state", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 2); + let h_a: i64 = conn + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![a.as_slice()], + |row| row.get(0), + ) + .unwrap(); + let h_b: i64 = conn + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![b.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(h_a, 11); + assert_eq!(h_b, 22); +} + +/// TC-023: one `flush(wallet_id)` produces exactly one SQLite +/// transaction. +/// +/// `rusqlite::Connection::commit_hook` registers a callback that fires +/// after every successful commit. We register it on the persister's +/// write connection, then drive a flush whose changeset touches +/// multiple sub-changesets (core sync state + wallet metadata + +/// platform addresses + token balances). The hook MUST fire exactly +/// once for the duration of the flush call, regardless of how many +/// tables were written. +#[test] +fn tc023_one_flush_is_one_transaction() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use dpp::prelude::Identifier; + use key_wallet::Network; + use platform_wallet::changeset::{TokenBalanceChangeSet, WalletMetadataEntry}; + + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0x90); + ensure_wallet_meta(&persister, &w); + // V002: token_balances FK targets identities(identity_id); seed + // the identity so the cross-area flush passes that constraint. + let owner = Identifier::from([0xA1u8; 32]); + common::ensure_identity(&persister, owner.as_bytes(), Some(&w)); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(core_with_height(7, 7)); + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + birth_height: 1, + }); + let mut balances = BTreeMap::new(); + let token = Identifier::from([0xA2u8; 32]); + balances.insert((owner, token), 9u64); + cs.token_balances = Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }); + persister.store(w, cs).unwrap(); + + // Install the commit hook AFTER buffering (which only mutates + // memory) and BEFORE flush. + let commits = Arc::new(AtomicUsize::new(0)); + { + let commits_clone = Arc::clone(&commits); + let conn = persister.lock_conn_for_test(); + conn.commit_hook(Some(move || { + commits_clone.fetch_add(1, Ordering::SeqCst); + false + })) + .expect("install commit hook"); + } + + persister.flush(w).unwrap(); + + // Remove the hook so the persister is reusable elsewhere. + { + let conn = persister.lock_conn_for_test(); + conn.commit_hook(None:: bool>) + .expect("remove commit hook"); + } + + assert_eq!( + commits.load(Ordering::SeqCst), + 1, + "expected exactly one COMMIT for the flush, got {}", + commits.load(Ordering::SeqCst) + ); +} + +// --------------------------------------------------------------------------- +// P2 — retry-safe flush +// --------------------------------------------------------------------------- + +use platform_wallet::changeset::PersistenceError; +use platform_wallet_storage::WalletStorageError; +use rusqlite::ErrorCode; + +fn make_busy_error() -> WalletStorageError { + WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseBusy, + extended_code: rusqlite::ffi::SQLITE_BUSY, + }, + Some("database is busy".into()), + )) +} + +fn make_fatal_error() -> WalletStorageError { + WalletStorageError::IntegrityCheckFailed { + report: "simulated fatal".into(), + } +} + +fn install_commit_counter( + persister: &platform_wallet_storage::SqlitePersister, +) -> std::sync::Arc { + use std::sync::atomic::Ordering; + use std::sync::Arc; + let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let conn = persister.lock_conn_for_test(); + conn.commit_hook(Some(move || { + counter_clone.fetch_add(1, Ordering::SeqCst); + false + })) + .expect("install commit hook"); + counter +} + +fn read_synced_height(path: &std::path::Path, w: &[u8; 32]) -> Option { + use rusqlite::OptionalExtension; + ro_conn(path) + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .optional() + .unwrap() +} + +/// TC-P2-001 — happy-path flush is one transaction; second flush is a no-op. +#[test] +fn tc_p2_001_happy_path_one_tx_then_noop() { + use std::sync::atomic::Ordering; + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(5, 5))) + .unwrap(); + let commits = install_commit_counter(&persister); + persister.flush(w).expect("first flush ok"); + persister.flush(w).expect("second flush ok (no-op)"); + assert_eq!( + commits.load(Ordering::SeqCst), + 1, + "expected exactly one COMMIT — buffer was empty on the second flush" + ); + assert_eq!(read_synced_height(&path, &w), Some(5)); +} + +/// TC-P2-002 — transient failure restores the buffer for retry. +#[test] +fn tc_p2_002_transient_failure_restores_buffer() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(7, 7))) + .unwrap(); + persister.force_next_flush_to_fail(make_busy_error()); + let err = persister.flush(w).expect_err("first flush must fail"); + let msg = match err { + PersistenceError::Backend { source, .. } => source.to_string(), + other => panic!("expected Backend {{ .. }}, got {other:?}"), + }; + assert!( + msg.contains("flush failed transiently"), + "expected FlushRetryable in message, got {msg}" + ); + // No injected error this time → second flush commits the buffered data. + persister.flush(w).expect("second flush ok"); + assert_eq!(read_synced_height(&path, &w), Some(7)); +} + +/// TC-P2-003 — store-during-failed-flush merges via LWW. +/// +/// Documented `Merge for CoreChangeSet` semantics (see +/// `platform_wallet/changeset/changeset.rs:150-220`): `synced_height` +/// and `last_processed_height` use monotonic-max merging, so the +/// final values are `max(A, B)` per field regardless of order. +#[test] +fn tc_p2_003_store_during_failed_flush_lww() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xC3); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(10, 10))) + .unwrap(); + persister.force_next_flush_to_fail(make_busy_error()); + let _err = persister.flush(w).expect_err("first flush must fail"); + // B arrives between failed flush and retry. + persister + .store(w, changeset(core_with_height(20, 5))) + .unwrap(); + persister.flush(w).expect("retry must succeed"); + assert_eq!(read_synced_height(&path, &w), Some(20)); + let lp: Option = { + use rusqlite::OptionalExtension; + ro_conn(&path) + .query_row( + "SELECT last_processed_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .optional() + .unwrap() + }; + assert_eq!(lp, Some(10), "monotonic-max merge must keep 10"); +} + +/// TC-P2-004 — fatal failure WIPES the buffer. +#[test] +fn tc_p2_004_fatal_failure_wipes_buffer() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xC4); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(9, 9))) + .unwrap(); + persister.force_next_flush_to_fail(make_fatal_error()); + let _err = persister.flush(w).expect_err("first flush must fail"); + // Buffer wiped — second flush is a no-op, no row written. + persister.flush(w).expect("second flush ok (no-op)"); + assert_eq!( + read_synced_height(&path, &w), + None, + "fatal failure must drop the buffered changeset" + ); +} + +/// TC-P2-006 — `FlushMode::Immediate` surfaces `FlushRetryable`. +#[test] +fn tc_p2_006_immediate_surfaces_flush_retryable() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Immediate); + let w = wid(0xC6); + ensure_wallet_meta(&persister, &w); + persister.force_next_flush_to_fail(make_busy_error()); + let err = persister + .store(w, changeset(core_with_height(3, 3))) + .expect_err("immediate store must surface the error"); + let msg = match err { + PersistenceError::Backend { source, .. } => source.to_string(), + other => panic!("expected Backend {{ .. }}, got {other:?}"), + }; + assert!( + msg.contains("flush failed transiently"), + "Immediate mode must surface FlushRetryable, got {msg}" + ); + // The store buffered the data via take_for_flush + restore. Issue + // a flush directly — the second attempt commits. + persister.flush(w).expect("retry ok"); + assert_eq!(read_synced_height(&path, &w), Some(3)); +} + +/// TC-P2-007 — restore emits a structured `tracing::warn!`. +#[tracing_test::traced_test] +#[test] +fn tc_p2_007_warn_on_restore_with_structured_fields() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xC7); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(8, 8))) + .unwrap(); + persister.force_next_flush_to_fail(make_busy_error()); + let _ = persister.flush(w).expect_err("first flush must fail"); + // tracing-test exposes a per-test buffer via `logs_contain`. + assert!( + logs_contain("flush failed transiently"), + "WARN message missing" + ); + assert!( + logs_contain("error_kind=\"sqlite_busy\""), + "structured error_kind missing" + ); + assert!( + logs_contain("restored_field_count=1"), + "structured restored_field_count missing" + ); + assert!( + logs_contain(&hex::encode(w)), + "structured wallet_id missing" + ); +} + +/// ATOM-007 (N-2): dropping a Manual-mode persister with uncommitted +/// dirty wallets logs a structured `tracing::error!`. We do NOT +/// auto-flush from Drop — the spec is explicit about this. +#[tracing_test::traced_test] +#[test] +fn atom_007_drop_logs_uncommitted_manual_buffer() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xDD); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(11, 11))) + .unwrap(); + // Buffer is now dirty. Dropping must emit the structured error. + drop(persister); + + assert!( + logs_contain("SqlitePersister dropped with uncommitted Manual-mode writes"), + "drop must emit error line" + ); + assert!( + logs_contain("dirty_wallets=1"), + "structured dirty_wallets field missing" + ); +} + +/// ATOM-007 (N-2): an Immediate-mode persister never trips the +/// Drop-time log — every `store` is durable, so there is no +/// uncommitted state by construction. +#[tracing_test::traced_test] +#[test] +fn atom_007_drop_silent_in_immediate_mode() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Immediate); + let w = wid(0xDE); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(11, 11))) + .unwrap(); + drop(persister); + assert!( + !logs_contain("SqlitePersister dropped with uncommitted"), + "Immediate-mode drop must NOT log uncommitted state" + ); +} + +/// ATOM-006 (N-1): `commit_writes` continues past per-wallet failures, +/// returning a CommitReport with each wallet's outcome. A failed +/// wallet is recorded in `failed`; the remaining wallets still flush. +/// +/// We use `force_next_flush_to_fail` to make the FIRST wallet in +/// sorted-id order surface a fatal error. The remaining two wallets +/// must still flush (sorted-id ordering — A < B < C), and the report +/// must list 1 failure + 2 successes. +#[test] +fn atom_006_commit_writes_continues_past_per_wallet_failures() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let a = wid(0xA0); + let b = wid(0xB0); + let c = wid(0xC0); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + ensure_wallet_meta(&persister, &c); + persister + .store(a, changeset(core_with_height(1, 1))) + .unwrap(); + persister + .store(b, changeset(core_with_height(2, 2))) + .unwrap(); + persister + .store(c, changeset(core_with_height(3, 3))) + .unwrap(); + + // The injector fires on the FIRST flush_inner. Wallets are flushed + // in sorted-id order, so it hits wallet A. + persister.force_next_flush_to_fail(make_fatal_error()); + let report = persister + .commit_writes() + .expect("commit_writes itself must return Ok(report)"); + + assert_eq!(report.failed.len(), 1, "wallet A must be in failed"); + assert_eq!(report.failed[0].0, a, "failed wallet must be A"); + assert_eq!( + report.succeeded.len(), + 2, + "B and C must still flush despite A's failure; report={report:?}" + ); + assert!(report.succeeded.contains(&b) && report.succeeded.contains(&c)); + assert!( + report.still_pending.is_empty(), + "no LockPoisoned short-circuit on a fatal error path" + ); + assert!(!report.is_ok()); + + // Verify B and C are durable; A is not. + assert_eq!(read_synced_height(&path, &a), None); + assert_eq!(read_synced_height(&path, &b), Some(2)); + assert_eq!(read_synced_height(&path, &c), Some(3)); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs b/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs new file mode 100644 index 00000000000..6ca76babb58 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs @@ -0,0 +1,262 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-056..TC-075 — CLI smoke tests. + +use std::process::Command; + +use assert_cmd::cargo::CommandCargoExt; + +fn cli() -> Command { + Command::cargo_bin("platform-wallet-storage").expect("bin built") +} + +/// TC-056: migrate on a fresh DB prints `applied: ` then `applied: 0`. +#[test] +fn tc056_migrate_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + let out = cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .unwrap(); + assert!(out.status.success(), "first migrate failed: {out:?}"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.starts_with("applied: ") && stdout.trim() != "applied: 0", + "unexpected first-run stdout: {stdout}" + ); + let out2 = cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .unwrap(); + assert!(out2.status.success(), "second migrate failed"); + let stdout2 = String::from_utf8_lossy(&out2.stdout); + assert_eq!(stdout2.trim(), "applied: 0"); +} + +/// TC-062: restore without --yes refuses (exit 2). +#[test] +fn tc062_restore_without_yes_refuses() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + let fake_src = tmp.path().join("not-a-backup.db"); + std::fs::write(&fake_src, b"x").unwrap(); + let out = cli() + .args([ + "--db", + db.to_str().unwrap(), + "restore", + "--from", + fake_src.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "expected exit 2; got {:?} stderr={}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); +} + +/// TC-065: prune without --keep-last or --max-age is a usage error. +#[test] +fn tc065_prune_requires_a_rule() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + let dir = tmp.path().join("bk"); + std::fs::create_dir(&dir).unwrap(); + let out = cli() + .args([ + "--db", + db.to_str().unwrap(), + "prune", + "--in", + dir.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); +} + +/// TC-070: invalid wallet-id format exits 2. +#[test] +fn tc070_inspect_invalid_wallet_id() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + for bad in ["zzzz", "00"] { + let out = cli() + .args(["--db", db.to_str().unwrap(), "inspect", "--wallet-id", bad]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "expected exit 2 for `{bad}`; got {:?}", + out.status.code() + ); + } +} + +/// TC-072: the `delete-wallet` subcommand is removed from the CLI +/// (CMT-007). Invoking it is an unknown-subcommand usage error. +#[test] +fn tc072_delete_wallet_subcommand_removed() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + let out = cli() + .args([ + "--db", + db.to_str().unwrap(), + "delete-wallet", + "--wallet-id", + &"aa".repeat(32), + ]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "expected clap usage exit code 2 for removed subcommand; got {:?}", + out.status.code() + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("unrecognized") || stderr.contains("unexpected"), + "expected clap unknown-subcommand error, got stderr: `{stderr}`" + ); +} + +/// TC-068: inspect TSV format prints `table\tcount` lines. +#[test] +fn tc068_inspect_tsv() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + let out = cli() + .args(["--db", db.to_str().unwrap(), "inspect", "--format", "tsv"]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + assert!( + lines.len() >= 18, + "expected ≥18 lines of TSV, got {}", + lines.len() + ); + for line in lines { + let cols: Vec<&str> = line.split('\t').collect(); + assert_eq!(cols.len(), 2, "bad TSV line: `{line}`"); + let n: i64 = cols[1].parse().expect(line); + assert!(n >= 0); + } +} + +/// TC-059: backup --out writes a timestamped file. +#[test] +fn tc059_backup_dir() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + let out_dir = tmp.path().join("bk"); + std::fs::create_dir(&out_dir).unwrap(); + let out = cli() + .args([ + "--db", + db.to_str().unwrap(), + "backup", + "--out", + out_dir.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(out.status.success(), "backup failed: {out:?}"); + let stdout = String::from_utf8_lossy(&out.stdout); + let path = stdout.trim(); + assert!(path.ends_with(".db")); + assert!(std::path::Path::new(path).exists()); +} + +/// TC-CODE-030-1a: the supported `--no-auto-backup` flag disables the +/// pre-migration auto-backup. `migrate --no-auto-backup` succeeds on a +/// fresh DB without writing the `backups/auto/` sentinel snapshot. +#[test] +fn tc_code_030_1a_no_auto_backup_disables() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + let out = cli() + .args(["--db", db.to_str().unwrap(), "migrate", "--no-auto-backup"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "migrate --no-auto-backup failed: {out:?}" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("auto-backup skipped (--no-auto-backup)"), + "expected `--no-auto-backup` notice on stderr, got: {stderr}" + ); + // No `backups/auto/pre-migration-*.db` written when the flag is set. + let auto_dir = tmp.path().join("backups").join("auto"); + if auto_dir.exists() { + let pre_mig: Vec<_> = std::fs::read_dir(&auto_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("pre-migration")) + .collect(); + assert!( + pre_mig.is_empty(), + "pre-migration backup written despite --no-auto-backup: {pre_mig:?}" + ); + } +} + +/// TC-CODE-030-1b: the legacy `--auto-backup-dir ""` sentinel still +/// works (one-release deprecation window) but emits a deprecation +/// warning on stderr steering operators toward `--no-auto-backup`. +#[test] +fn tc_code_030_1b_empty_auto_backup_dir_deprecated() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + let out = cli() + .args([ + "--db", + db.to_str().unwrap(), + "--auto-backup-dir", + "", + "migrate", + "--no-auto-backup", + ]) + .output() + .unwrap(); + assert!( + out.status.success(), + "migrate with deprecated empty --auto-backup-dir failed: {out:?}" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("deprecated") && stderr.contains("--no-auto-backup"), + "expected deprecation warning steering to --no-auto-backup, got: {stderr}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs new file mode 100644 index 00000000000..0916630bda7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -0,0 +1,176 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-076, TC-077, TC-078 — compile-time assertions. +//! TC-P1-003 — every writer call site uses `prepare_cached`. +//! TC-P4-011 — `ClientStartState` keeps the base public shape. + +use std::sync::Arc; + +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use static_assertions::assert_impl_all; + +assert_impl_all!(SqlitePersister: Send, Sync, PlatformWalletPersistence); + +/// TC-078: SqlitePersister fits behind Arc. +#[test] +fn tc078_object_safety() { + fn accepts(_: Arc) {} + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path); + let p = SqlitePersister::open(cfg).unwrap(); + let arc: Arc = Arc::new(p); + accepts(arc); +} + +/// Read-only SELECT call sites where `prepare(` is allowed (per FR-P1-1). +/// Every other writer in `schema/` MUST use `prepare_cached`. Match key +/// is the line content (substring) — line numbers shift, contents +/// rarely do. +const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ + ( + "wallet_meta.rs", + "SELECT wallet_id FROM wallet_metadata ORDER BY wallet_id", + ), + ( + "wallet_meta.rs", + "SELECT network, birth_height FROM wallet_metadata WHERE wallet_id", + ), + ("asset_locks.rs", "SELECT outpoint, account_index"), + ("platform_addrs.rs", "SELECT account_index, address_index"), + ("core_state.rs", "SELECT outpoint, value, script, height"), + // P4 readers — `load_state` per area uses one-shot SELECTs. + ( + "identities.rs", + "SELECT identity_id, entry_blob, tombstoned", + ), + ( + "contacts.rs", + "SELECT owner_id, recipient_id, entry_blob FROM contacts_sent", + ), + ( + "contacts.rs", + "SELECT owner_id, sender_id, entry_blob FROM contacts_recv", + ), + ( + "contacts.rs", + "SELECT owner_id, contact_id, entry_blob FROM contacts_established", + ), +]; + +/// TC-P1-003: writer paths in `src/sqlite/schema/*.rs` must not call +/// `prepare(`. Read-only SELECTs explicitly listed in +/// `READ_ONLY_PREPARE_ALLOWED` (per FR-P1-1) are exempt; every other +/// call site must use `prepare_cached`. +#[test] +fn tc_p1_003_prepare_cached_in_writers() { + let schema_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("sqlite") + .join("schema"); + let mut offenders: Vec<(String, usize, String)> = Vec::new(); + for entry in std::fs::read_dir(&schema_dir).expect("read schema dir") { + let entry = entry.expect("schema dir entry"); + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + if !file_name.ends_with(".rs") { + continue; + } + if file_name == "mod.rs" || file_name == "blob.rs" { + continue; + } + let body = std::fs::read_to_string(&path).expect("read schema file"); + let lines: Vec<&str> = body.lines().collect(); + for (idx, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") { + continue; + } + if !line.contains(".prepare(") { + continue; + } + // SQL may be on this line or the following two — concat + // and probe each allow-list substring. + let probe: String = lines + .iter() + .skip(idx) + .take(3) + .copied() + .collect::>() + .join("\n"); + let allowed = READ_ONLY_PREPARE_ALLOWED + .iter() + .any(|(f, sql)| *f == file_name && probe.contains(sql)); + if allowed { + continue; + } + offenders.push((file_name.to_string(), idx + 1, (*line).to_string())); + } + } + assert!( + offenders.is_empty(), + "writer paths must use `prepare_cached`; offenders: {:#?}", + offenders + ); +} + +/// TC-P4-011: `ClientStartState` keeps the base public shape — plain +/// (NOT `#[non_exhaustive]`) and carrying exactly the two wired-up +/// slots `platform_addresses` + `wallets`. The persister populates +/// only `platform_addresses`; any reintroduction of `#[non_exhaustive]` +/// or extra slots is a breaking-API regression for downstream callers +/// that destructure the struct exhaustively. +#[test] +fn tc_p4_011_client_start_state_base_shape() { + let upstream = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("packages/") + .join("rs-platform-wallet/src/changeset/client_start_state.rs"); + let body = std::fs::read_to_string(&upstream).expect("read client_start_state.rs"); + + let mut prev_non_exhaustive = false; + let mut found = false; + for line in body.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("#[non_exhaustive]") { + prev_non_exhaustive = true; + continue; + } + if trimmed.starts_with("pub struct ClientStartState") { + found = true; + assert!( + !prev_non_exhaustive, + "`ClientStartState` must NOT be `#[non_exhaustive]` — the \ + base public shape was restored (PR #3643 thread #7)" + ); + break; + } + if !trimmed.is_empty() + && !trimmed.starts_with("///") + && !trimmed.starts_with("//") + && !trimmed.starts_with("#[derive") + { + prev_non_exhaustive = false; + } + } + assert!( + found, + "did not encounter `pub struct ClientStartState` declaration" + ); + + for field in ["platform_addresses:", "wallets:"] { + assert!( + body.contains(field), + "base `ClientStartState` must keep the `{field}` slot" + ); + } + for removed in ["identities:", "contacts:", "asset_locks:"] { + assert!( + !body.contains(removed), + "`ClientStartState` must not carry the reverted `{removed}` slot" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs new file mode 100644 index 00000000000..c711c6d3107 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs @@ -0,0 +1,308 @@ +#![allow(clippy::field_reassign_with_default)] + +//! CMT-001 / CODE-006 — `delete_wallet` must reconcile the in-memory +//! buffer AND fold buffered writes into the pre-delete backup. +//! +//! `delete_wallet_inner` drains the target wallet's buffered +//! changeset, flushes it to disk, snapshots the backup, then runs +//! the cascade. These regression tests pin the failure modes: a +//! buffered-only wallet must delete cleanly without spurious +//! `WalletNotFound`; the pre-delete backup must contain buffered-but- +//! unflushed rows; a transient pre-flush failure must restore the +//! buffer and abort the delete without producing a backup; a peer +//! deleting the wallet in the post-snapshot / pre-cascade window +//! still yields a successful (no-op) cascade; the flush must not +//! resurrect a deleted wallet. + +mod common; + +use common::{fresh_persister_with_mode, wid}; +use key_wallet::Network; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet_storage::{ + FlushMode, SqlitePersister, SqlitePersisterConfig, WalletStorageError, +}; +use rusqlite::{ErrorCode, OptionalExtension}; + +/// A self-consistent changeset: includes `wallet_metadata` so a flush +/// would materialize a brand-new wallet (FK-valid) — modelling a +/// wallet whose only state is buffered. +fn full_changeset(synced: u32) -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet::default(); + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + birth_height: 0, + }); + cs.core = Some(CoreChangeSet { + synced_height: Some(synced), + last_processed_height: Some(synced), + ..Default::default() + }); + cs +} + +fn busy_error() -> WalletStorageError { + WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseBusy, + extended_code: rusqlite::ffi::SQLITE_BUSY, + }, + Some("database is busy".into()), + )) +} + +fn core_rows_for(persister: &SqlitePersister, w: &[u8; 32]) -> i64 { + let conn = persister.lock_conn_for_test(); + conn.query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap() +} + +/// Buffered-only wallet (no persisted row) deletes successfully and a +/// later `commit_writes` cannot resurrect its rows. +#[test] +fn buffered_only_delete_is_ok_and_no_resurrection() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xB1); + persister.store(w, full_changeset(5)).unwrap(); + + persister + .delete_wallet_skip_backup(w) + .expect("buffered-only delete must be Ok, not WalletNotFound"); + + persister.commit_writes().expect("commit_writes"); + assert_eq!( + core_rows_for(&persister, &w), + 0, + "buffered changeset must not resurrect the deleted wallet" + ); +} + +/// TC-CODE-006-1 — the pre-delete backup MUST include buffered +/// writes flushed during `delete_wallet`'s pre-flush phase. Without +/// the pre-flush, rollback-from-backup couldn't recover a wallet +/// whose only state lived in the buffer. +#[test] +fn pre_delete_backup_includes_buffered_writes() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let backup_dir = tmp.path().join("backups"); + let cfg = SqlitePersisterConfig::new(&path) + .with_flush_mode(FlushMode::Manual) + .with_auto_backup_dir(Some(backup_dir)); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xB2); + persister.store(w, full_changeset(9)).unwrap(); + + let report = persister.delete_wallet(w).expect("delete_wallet"); + let backup_path = report.backup_path.expect("pre-delete backup written"); + + let backup = rusqlite::Connection::open_with_flags( + &backup_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .unwrap(); + let in_backup_core: Option = backup + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .optional() + .unwrap(); + let in_backup_meta: Option = backup + .query_row( + "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert_eq!( + in_backup_core, + Some(1), + "pre-delete backup must contain the flushed buffered core_sync_state row" + ); + assert_eq!( + in_backup_meta, + Some(1), + "pre-delete backup must contain the flushed buffered wallet_metadata row" + ); +} + +/// TC-CODE-006-2 — when the pre-flush fails, the buffer is restored, +/// no backup is produced, the wallet stays in the live DB, and +/// `delete_wallet` surfaces the original error. +#[test] +fn pre_flush_failure_preserves_buffer_and_skips_backup() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let backup_dir = tmp.path().join("backups"); + let cfg = SqlitePersisterConfig::new(&path) + .with_flush_mode(FlushMode::Manual) + .with_auto_backup_dir(Some(backup_dir.clone())); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xC1); + + // Seed wallet_metadata so the wallet exists in the live DB. + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + } + + // Buffer a changeset so `delete_wallet` enters the pre-flush + // branch, then prime the pre-flush injector to fail. + persister.store(w, full_changeset(11)).unwrap(); + persister.force_next_pre_flush_to_fail(busy_error()); + + let err = persister + .delete_wallet(w) + .expect_err("pre-flush failure must propagate as Err"); + assert!( + matches!(err, WalletStorageError::Sqlite(_)), + "expected Sqlite error from primed pre-flush failure, got {err:?}" + ); + + // Backup dir holds no PreDelete file (dir may not even exist if + // `run_auto_backup` never ran — both are acceptable). + let entries: Vec<_> = std::fs::read_dir(&backup_dir) + .map(|it| it.filter_map(Result::ok).collect()) + .unwrap_or_default(); + assert!( + entries.is_empty(), + "pre-flush failure must not leave a backup behind: {entries:?}" + ); + + // Wallet still in the live DB, buffer still holds the changeset. + let meta_rows: i64 = { + let conn = persister.lock_conn_for_test(); + conn.query_row( + "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(meta_rows, 1, "wallet must remain in the live DB"); + assert!( + persister.buffer_has_changeset_for_test(&w), + "buffer must still hold the changeset after a failed pre-flush" + ); +} + +/// TC-CODE-006-3 — a peer that deletes the wallet's metadata row in +/// the post-snapshot / pre-cascade window still yields a successful +/// `delete_wallet`: the cascade is a no-op (counts are zero) and the +/// backup, taken before the peer mutation, still carries the wallet's +/// pre-deletion state. +#[test] +fn peer_delete_between_backup_and_exclusive_returns_ok_with_zero_counts() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let backup_dir = tmp.path().join("backups"); + let cfg = SqlitePersisterConfig::new(&path) + .with_flush_mode(FlushMode::Manual) + .with_auto_backup_dir(Some(backup_dir)); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xC3); + persister.store(w, full_changeset(13)).unwrap(); + + // Arm a hook that fires after the pre-delete backup snapshot and + // before the cascade `BEGIN EXCLUSIVE`. The hook opens a sibling + // raw connection to the same DB and deletes the wallet's metadata + // row — simulating a cross-process peer. + let peer_path = path.clone(); + persister.arm_post_backup_hook(move || { + let peer = rusqlite::Connection::open(&peer_path).expect("peer open"); + peer.execute("PRAGMA foreign_keys = ON", []).unwrap(); + peer.execute( + "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![&[0xC3u8; 32][..]], + ) + .expect("peer delete"); + }); + + let report = persister + .delete_wallet(w) + .expect("delete_wallet must succeed even when a peer races the cascade"); + let backup_path = report.backup_path.expect("backup written before peer race"); + + // Cascade reports zero rows removed because the peer beat it to + // every table. + for (_table, count) in report.rows_removed_per_table.iter() { + assert_eq!( + *count, 0, + "peer-raced cascade should observe zero per-table counts" + ); + } + + // Backup still contains the pre-peer-deletion state. + let backup = rusqlite::Connection::open_with_flags( + &backup_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .unwrap(); + let in_backup_meta: i64 = backup + .query_row( + "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + in_backup_meta, 1, + "backup must carry the wallet that existed at snapshot time" + ); +} + +/// A wallet that exists in neither the buffer nor the DB still returns +/// `WalletNotFound` — under both flush modes. +#[test] +fn delete_unknown_wallet_is_not_found() { + for mode in [FlushMode::Manual, FlushMode::Immediate] { + let (persister, _tmp, _path) = fresh_persister_with_mode(mode); + let w = wid(0xB3); + let err = persister.delete_wallet_skip_backup(w); + assert!( + matches!(err, Err(WalletStorageError::WalletNotFound { .. })), + "expected WalletNotFound in {mode:?}, got {err:?}" + ); + } +} + +/// Immediate mode: a transient flush failure restores the changeset to +/// the buffer; a subsequent delete must drain it so no later +/// `commit_writes`/flush resurrects the wallet. +#[test] +fn immediate_after_failed_flush_delete_drains_buffer() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Immediate); + let w = wid(0xB4); + + persister.force_next_flush_to_fail(busy_error()); + let _ = persister + .store(w, full_changeset(7)) + .expect_err("immediate store surfaces the transient error"); + // The changeset is now restored to the buffer. + + persister + .delete_wallet_skip_backup(w) + .expect("delete must be Ok with a restored-after-failure buffer entry"); + + persister.commit_writes().expect("commit_writes"); + persister.flush(w).expect("flush"); + assert_eq!( + core_rows_for(&persister, &w), + 0, + "restored buffered changeset must not resurrect the deleted wallet" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs new file mode 100644 index 00000000000..0fa07cd3e22 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs @@ -0,0 +1,102 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-CODE-007 — `delete_wallet` must hold a SQLite-native EXCLUSIVE +//! across the (backup + cascade-delete) window so a peer rusqlite +//! Connection (a different process equivalent) can't commit rows +//! between the backup snapshot and the cascade. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use rusqlite::TransactionBehavior; + +/// When a peer holds EXCLUSIVE on the destination, `delete_wallet` +/// must block / fail on busy rather than proceeding through an +/// in-process-mutex-only path that ignores the peer. +#[test] +fn delete_wallet_blocks_when_peer_holds_exclusive() { + let (persister, _tmp, db_path) = fresh_persister(); + let w = wid(0x77); + ensure_wallet_meta(&persister, &w); + + let backup_dir = tempfile::tempdir().expect("backup dir"); + // Wire the persister with auto-backup so delete_wallet exercises + // the backup + cascade path (the canonical path under test). + // Re-open persister using a config that knows about the dir. + drop(persister); + let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&db_path) + .with_auto_backup_dir(Some(backup_dir.path().to_path_buf())); + let persister = platform_wallet_storage::SqlitePersister::open(cfg).expect("re-open"); + ensure_wallet_meta(&persister, &w); + + // Peer opens a writer conn against the same DB file and takes + // EXCLUSIVE — represents "another process holds the DB busy". + let mut peer = rusqlite::Connection::open(&db_path).expect("peer open"); + peer.pragma_update(None, "busy_timeout", 50i64).unwrap(); + let tx = peer + .transaction_with_behavior(TransactionBehavior::Exclusive) + .expect("peer EXCLUSIVE"); + + // Set the persister's busy-timeout low so the test doesn't hang. + { + let conn = persister.lock_conn_for_test(); + conn.pragma_update(None, "busy_timeout", 50i64).unwrap(); + } + + let err = persister + .delete_wallet(w) + .expect_err("delete must conflict with peer EXCLUSIVE"); + // Detect the busy/locked condition either through the SqliteFailure + // error code or the source rusqlite error string (which renders + // "database is locked" etc.). Falls back to a substring check on + // the Display form so the assertion is robust across rusqlite + // versions and the exact path that surfaced the conflict (open vs. + // begin vs. cascade). + let busy = matches!( + &err, + platform_wallet_storage::WalletStorageError::Sqlite( + rusqlite::Error::SqliteFailure(e, _) + ) if matches!( + e.code, + rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked + ) + ); + let dbg = format!("{err:?}"); + assert!( + busy || dbg.contains("Busy") + || dbg.contains("Locked") + || dbg.contains("database is locked"), + "expected busy/locked SQLite error, got: {err} | dbg: {dbg}" + ); + + drop(tx); + drop(peer); +} + +/// Single-process load (regression for CMT-002 / CMT-008 invariants) +/// must still pass after the EXCLUSIVE refactor. +#[test] +fn delete_wallet_single_process_still_works() { + let (persister, _tmp, db_path) = fresh_persister(); + let backup_dir = tempfile::tempdir().expect("backup dir"); + drop(persister); + let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&db_path) + .with_auto_backup_dir(Some(backup_dir.path().to_path_buf())); + let persister = platform_wallet_storage::SqlitePersister::open(cfg).expect("re-open"); + + let w = wid(0x88); + ensure_wallet_meta(&persister, &w); + + let report = persister.delete_wallet(w).expect("delete succeeds"); + assert!(report.backup_path.is_some(), "auto-backup should fire"); + // wallet_metadata row should be gone. + let conn = persister.lock_conn_for_test(); + let row: Option = conn + .query_row( + "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .ok(); + assert!(row.is_none(), "wallet_metadata row must be gone"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs new file mode 100644 index 00000000000..af9df9e7e7d --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs @@ -0,0 +1,164 @@ +#![allow(clippy::field_reassign_with_default)] + +//! CMT-002 — `delete_wallet` must restore the drained buffered +//! changeset on any pre-commit failure. Mirrors the take/restore +//! discipline `flush_inner` uses so the operator's pending writes +//! survive a failed delete. + +mod common; + +use std::path::PathBuf; +use std::sync::Arc; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::{ + FlushMode, SqlitePersister, SqlitePersisterConfig, WalletStorageError, +}; + +/// Open a persister with Manual flush mode + an explicitly bad +/// auto-backup directory so `run_auto_backup` reliably fails. +fn persister_with_bad_backup_dir() -> (SqlitePersister, tempfile::TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("wallet.db"); + let bad_dir = tmp.path().join("does-not-exist").join("nested"); + // Point auto_backup_dir at a path whose grand-parent doesn't exist + // — write attempts fail without create-mode magic. + std::fs::create_dir(tmp.path().join("does-not-exist")).unwrap(); + let cfg = SqlitePersisterConfig::new(&db) + .with_flush_mode(FlushMode::Manual) + .with_auto_backup_dir(Some(bad_dir)); + // Take the parent away so create_dir_all fails. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + tmp.path().join("does-not-exist"), + std::fs::Permissions::from_mode(0o500), + ) + .unwrap(); + } + let persister = SqlitePersister::open(cfg).expect("open"); + (persister, tmp, db) +} + +#[test] +fn delete_wallet_restores_buffer_on_backup_failure() { + let (persister, _tmp, _path) = persister_with_bad_backup_dir(); + let w = wid(0x42); + ensure_wallet_meta(&persister, &w); + + // Stage a buffered changeset on top of the persisted parent. + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(99), + last_processed_height: Some(99), + ..Default::default() + }); + persister.store(w, cs).expect("store"); + + // Delete fails because the auto-backup dir is unwritable. + let err = persister + .delete_wallet(w) + .expect_err("delete must fail when auto-backup is unwritable"); + assert!( + matches!(err, WalletStorageError::AutoBackupDirUnwritable { .. }), + "expected AutoBackupDirUnwritable, got {err:?}" + ); + + // The buffer MUST still hold the staged changeset — flush it and + // observe the height landed in core_sync_state. + persister.flush(w).expect("flush after failed delete"); + + let conn = persister.lock_conn_for_test(); + let h: i64 = conn + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .expect("core_sync_state row should exist post-flush"); + assert_eq!( + h, 99, + "buffered changeset must survive a failed delete and flush cleanly" + ); +} + +/// CMT-008: a concurrent `store()` racing against `delete_wallet` must +/// not leave persisted rows behind. We spawn a worker hammering +/// `store(wallet_id, cs)` while the main thread calls `delete_wallet`, +/// then commit any remaining buffered writes and assert every +/// per-wallet table holds zero rows for that wallet_id. +#[test] +fn concurrent_store_does_not_resurrect_deleted_wallet() { + use platform_wallet_storage::sqlite::schema::{count_rows_for_wallet_sql, PER_WALLET_TABLES}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::thread; + + let (persister, _tmp, _path) = fresh_persister(); + let persister = Arc::new(persister); + let w = wid(0xCC); + ensure_wallet_meta(&persister, &w); + + // Seed at least one persisted child row. + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(1), + last_processed_height: Some(1), + ..Default::default() + }); + persister.store(w, cs).expect("seed store"); + + let stop = Arc::new(AtomicBool::new(false)); + let worker = { + let persister = Arc::clone(&persister); + let stop = Arc::clone(&stop); + thread::spawn(move || { + let mut i = 2u32; + while !stop.load(Ordering::Relaxed) { + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(i), + last_processed_height: Some(i), + ..Default::default() + }); + // Race against delete — either succeeds or hits a + // wallet-gone state. Both are acceptable: we only care + // that no row survives the delete commit. + let _ = persister.store(w, cs); + i = i.wrapping_add(1); + std::thread::yield_now(); + } + }) + }; + + // Give the worker a moment to land some racing stores. + std::thread::sleep(std::time::Duration::from_millis(20)); + persister + .delete_wallet(w) + .expect("delete_wallet should succeed in concurrent-store regression"); + stop.store(true, Ordering::Relaxed); + worker.join().unwrap(); + + // Drain any remaining buffered writes — these MUST also leave the + // wallet at zero rows because delete_wallet wiped the buffer post- + // commit (CMT-008 post-commit re-drain). + let _ = persister.commit_writes(); + + let conn = persister.lock_conn_for_test(); + for (table, scope) in PER_WALLET_TABLES { + let n: i64 = conn + .query_row( + &count_rows_for_wallet_sql(table, *scope), + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap_or_else(|e| panic!("COUNT(*) query failed for table `{table}`: {e}")); + assert_eq!( + n, 0, + "table `{table}` still has rows for deleted wallet — concurrent-store race regression" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs new file mode 100644 index 00000000000..b734a6a3c54 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -0,0 +1,324 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-P2-005 — `WalletStorageError::is_transient` and +//! `error_kind_str` exhaustiveness check via wildcard-free `match`. +//! TC-P2-010 — boundary mapping `FlushRetryable` → +//! `PersistenceError::Backend`. +//! +//! TC-P2-005 is structured as a `match` over `&WalletStorageError` +//! that covers every variant explicitly. There is NO `_` arm — when a +//! future variant lands on `WalletStorageError`, this file refuses to +//! compile until the author adds a classification + tag here too. +//! Combined with the wildcard-free matches in +//! `error::is_transient` / `error::error_kind_str` and the workspace +//! ban on `#[non_exhaustive]` for this enum, the policy is enforced +//! at the type system level end-to-end. + +use std::path::PathBuf; + +use platform_wallet::changeset::PersistenceError; +use platform_wallet_storage::sqlite::error::AutoBackupOperation; +use platform_wallet_storage::sqlite::util::safe_cast::SafeCastTarget; +use platform_wallet_storage::WalletStorageError; +use rusqlite::{Error as SqlErr, ErrorCode}; + +fn sqlite_busy() -> WalletStorageError { + WalletStorageError::Sqlite(SqlErr::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseBusy, + extended_code: rusqlite::ffi::SQLITE_BUSY, + }, + Some("database is busy".into()), + )) +} + +fn sqlite_locked() -> WalletStorageError { + WalletStorageError::Sqlite(SqlErr::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseLocked, + extended_code: rusqlite::ffi::SQLITE_LOCKED, + }, + Some("database table is locked".into()), + )) +} + +fn sqlite_corrupt() -> WalletStorageError { + WalletStorageError::Sqlite(SqlErr::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseCorrupt, + extended_code: rusqlite::ffi::SQLITE_CORRUPT, + }, + Some("disk image malformed".into()), + )) +} + +fn sqlite_disk_full() -> WalletStorageError { + WalletStorageError::Sqlite(SqlErr::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DiskFull, + extended_code: rusqlite::ffi::SQLITE_FULL, + }, + Some("database or disk is full".into()), + )) +} + +fn sqlite_io_failure() -> WalletStorageError { + WalletStorageError::Sqlite(SqlErr::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::SystemIoFailure, + extended_code: rusqlite::ffi::SQLITE_IOERR, + }, + Some("disk I/O error".into()), + )) +} + +fn sqlite_oom() -> WalletStorageError { + WalletStorageError::Sqlite(SqlErr::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::OutOfMemory, + extended_code: rusqlite::ffi::SQLITE_NOMEM, + }, + Some("out of memory".into()), + )) +} + +/// One representative sample per `WalletStorageError` variant. +/// +/// The samples are passed through a wildcard-free `match` below; the +/// compiler enforces every variant is named. `Sqlite(_)` and the +/// `FlushRetryable` retry path are split into their classified +/// sub-cases (busy / locked / non-retryable) inside the body. +fn samples() -> Vec { + vec![ + WalletStorageError::Io(std::io::Error::other("boom")), + sqlite_busy(), + sqlite_locked(), + sqlite_corrupt(), + sqlite_disk_full(), + sqlite_io_failure(), + sqlite_oom(), + // Migration uses an internal refinery error — we cannot easily + // synthesise one without a full runner. The `Migration(_)` arm + // in the match below uses a lazily-generated value via + // `unimplemented_variant_marker` since the test body never + // reads the inner error. We construct a different concrete + // variant whose match arm is `Migration` — see comment in arm. + // Skipped from samples because refinery::Error has no public + // `From` we can lean on; the arm is still exhaustively + // covered by the match itself. + WalletStorageError::IntegrityCheckFailed { + report: "rows missing".into(), + }, + WalletStorageError::IntegrityCheckRunFailed { + source: SqlErr::ExecuteReturnedResults, + }, + WalletStorageError::SourceOpenFailed { + source: SqlErr::ExecuteReturnedResults, + }, + WalletStorageError::SchemaHistoryMissing, + WalletStorageError::SchemaVersionUnsupported { + found: 99, + max_supported: 3, + }, + WalletStorageError::AutoBackupDisabled { + operation: AutoBackupOperation::DeleteWallet, + }, + WalletStorageError::AutoBackupDirUnwritable { + dir: PathBuf::from("/nope"), + source: std::io::Error::other("nope"), + }, + WalletStorageError::WalletNotFound { + wallet_id: [0u8; 32], + }, + WalletStorageError::WalletIdMismatch { + expected: [1u8; 32], + found: [2u8; 32], + }, + WalletStorageError::IdentityKeyEntryMismatch, + WalletStorageError::AssetLockEntryMismatch { + typed_outpoint: "txid:0".into(), + blob_outpoint: "txid:1".into(), + typed_account_index: 5, + blob_account_index: 9, + }, + WalletStorageError::BlobTooLarge { + len_bytes: 32 * 1024 * 1024, + limit_bytes: 16 * 1024 * 1024, + }, + WalletStorageError::ForeignKeysNotEnforced, + WalletStorageError::LockPoisoned, + WalletStorageError::RestoreDestinationLocked, + WalletStorageError::InvalidWalletIdHex { + source: hex::FromHexError::OddLength, + }, + WalletStorageError::InvalidWalletIdLength { actual: 10 }, + WalletStorageError::ConfigInvalid { reason: "bad knob" }, + // BincodeEncode / BincodeDecode / HashDecode / ConsensusCodec + // need real upstream errors — synthesise minimal ones via the + // public constructors / `From` impls. + WalletStorageError::BlobDecode { + reason: "bad shape", + }, + WalletStorageError::BackupDestinationExists { + path: PathBuf::from("/x"), + }, + WalletStorageError::IntegerOverflow { + field: "f", + value: u64::MAX, + target: SafeCastTarget::U64, + }, + WalletStorageError::FlushRetryable { + wallet_id: [0xAB; 32], + source: SqlErr::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseBusy, + extended_code: rusqlite::ffi::SQLITE_BUSY, + }, + Some("busy".into()), + ), + }, + WalletStorageError::MigrationRequiresManualCleanup { + table: "token_balances", + count: 3, + }, + ] +} + +/// TC-P2-005: wildcard-free exhaustiveness gate. +/// +/// The body is a `match` over `&WalletStorageError` with one arm per +/// variant — NO `_` arm, NO `..` rest patterns over enum variants. +/// Adding a new variant to `WalletStorageError` triggers a compile +/// error here AND in `error::is_transient`; the two failures together +/// keep the classification policy honest. +#[test] +fn tc_p2_005_is_transient_table() { + fn classify(err: &WalletStorageError) -> (bool, &'static str) { + // Every arm asserts the expected (transient, kind_str) pair + // and returns it for the outer assertion. A new variant + // landing in WalletStorageError makes this match fail to + // compile until classified. + match err { + // SQLite path discriminates by inner ErrorCode — split + // into busy / locked / other to mirror error_kind_str. + WalletStorageError::Sqlite(SqlErr::SqliteFailure(e, _)) => match e.code { + ErrorCode::DatabaseBusy => (true, "sqlite_busy"), + ErrorCode::DatabaseLocked => (true, "sqlite_locked"), + ErrorCode::DiskFull => (true, "sqlite_disk_full"), + ErrorCode::SystemIoFailure => (true, "sqlite_io_failure"), + ErrorCode::OutOfMemory => (true, "sqlite_out_of_memory"), + _ => (false, "sqlite_other"), + }, + WalletStorageError::Sqlite(_) => (false, "sqlite_other"), + WalletStorageError::FlushRetryable { .. } => (true, "flush_retryable"), + WalletStorageError::Io(_) => (false, "io"), + WalletStorageError::Migration(_) => (false, "migration"), + WalletStorageError::IntegrityCheckFailed { .. } => (false, "integrity_check_failed"), + WalletStorageError::IntegrityCheckRunFailed { .. } => { + (false, "integrity_check_run_failed") + } + WalletStorageError::SourceOpenFailed { .. } => (false, "source_open_failed"), + WalletStorageError::SchemaHistoryMissing => (false, "schema_history_missing"), + WalletStorageError::SchemaVersionUnsupported { .. } => { + (false, "schema_version_unsupported") + } + WalletStorageError::AutoBackupDisabled { .. } => (false, "auto_backup_disabled"), + WalletStorageError::AutoBackupDirUnwritable { .. } => { + (false, "auto_backup_dir_unwritable") + } + WalletStorageError::WalletNotFound { .. } => (false, "wallet_not_found"), + WalletStorageError::WalletIdMismatch { .. } => (false, "wallet_id_mismatch"), + WalletStorageError::LockPoisoned => (false, "lock_poisoned"), + WalletStorageError::RestoreDestinationLocked => (false, "restore_destination_locked"), + WalletStorageError::InvalidWalletIdHex { .. } => (false, "invalid_wallet_id_hex"), + WalletStorageError::InvalidWalletIdLength { .. } => (false, "invalid_wallet_id_length"), + WalletStorageError::ConfigInvalid { .. } => (false, "config_invalid"), + WalletStorageError::BincodeEncode { .. } => (false, "bincode_encode"), + WalletStorageError::BincodeDecode { .. } => (false, "bincode_decode"), + WalletStorageError::BlobDecode { .. } => (false, "blob_decode"), + WalletStorageError::HashDecode { .. } => (false, "hash_decode"), + WalletStorageError::ConsensusCodec { .. } => (false, "consensus_codec"), + WalletStorageError::BackupDestinationExists { .. } => { + (false, "backup_destination_exists") + } + WalletStorageError::IdentityKeyEntryMismatch => (false, "identity_key_entry_mismatch"), + WalletStorageError::AssetLockEntryMismatch { .. } => { + (false, "asset_lock_entry_mismatch") + } + WalletStorageError::BlobTooLarge { .. } => (false, "blob_too_large"), + WalletStorageError::ForeignKeysNotEnforced => (false, "foreign_keys_not_enforced"), + WalletStorageError::IntegerOverflow { .. } => (false, "integer_overflow"), + WalletStorageError::MigrationRequiresManualCleanup { .. } => { + (false, "migration_requires_manual_cleanup") + } + } + } + + for err in samples() { + let (expected_transient, expected_kind) = classify(&err); + assert_eq!( + err.is_transient(), + expected_transient, + "is_transient mismatch for variant `{expected_kind}`: got {}", + err.is_transient() + ); + assert_eq!( + err.error_kind_str(), + expected_kind, + "error_kind_str mismatch for variant `{expected_kind}`: got {}", + err.error_kind_str() + ); + } +} + +/// TC-P2-010: `FlushRetryable` flowing through the `From` impl into +/// `PersistenceError::Backend { kind, source }` (CODE-004): the outer +/// `Display` carries the variant markers ops grep for, and the typed +/// source chain still reaches the inner rusqlite payload (consumers +/// downcast or `Error::source`-walk to get there). +#[test] +fn tc_p2_010_boundary_error_mapping() { + let err = WalletStorageError::FlushRetryable { + wallet_id: [0xAB; 32], + source: rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseBusy, + extended_code: rusqlite::ffi::SQLITE_BUSY, + }, + Some("database is locked".into()), + ), + }; + let pe: PersistenceError = err.into(); + let source = match pe { + PersistenceError::Backend { source, .. } => source, + other => panic!("expected Backend {{ .. }}, got {other:?}"), + }; + let outer = source.to_string(); + assert!( + outer.contains("FlushRetryable"), + "missing FlushRetryable variant marker: {outer}" + ); + assert!( + outer.contains("flush failed transiently"), + "missing FlushRetryable display body: {outer}" + ); + assert!( + outer.contains("abab"), + "missing wallet_id hex prefix: {outer}" + ); + + // Walk the typed source chain to the inner rusqlite payload — + // post-CODE-004 the source is `Box` so + // the chain is preserved structurally, not just stringified. + let mut chain = String::new(); + let mut cur: Option<&(dyn std::error::Error + 'static)> = source.source(); + while let Some(e) = cur { + chain.push_str(&e.to_string()); + chain.push('\n'); + cur = e.source(); + } + assert!( + chain.contains("database is locked"), + "inner source text missing from chain walk: {chain}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs new file mode 100644 index 00000000000..8d8247bd036 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs @@ -0,0 +1,130 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-045..TC-048 — native foreign-key enforcement. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; + +/// TC-045: PRAGMA foreign_keys is ON on the connection. +#[test] +fn tc045_foreign_keys_on() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let fk: i64 = conn + .query_row("SELECT * FROM pragma_foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(fk, 1, "foreign_keys pragma not ON"); +} + +/// TC-046: insert into a child table without a wallet_metadata parent fails. +#[test] +fn tc046_orphan_child_insert_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + use rusqlite::params; + let res = conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, NULL, NULL)", + params![[99u8; 32].as_slice()], + ); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("FOREIGN KEY"), + "expected FOREIGN KEY constraint failure, got `{err}`" + ); +} + +/// TC-047: deleting wallet_metadata cascades. +#[test] +fn tc047_delete_wallet_cascade() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xC0); + ensure_wallet_meta(&persister, &w); + // Insert one row into a child table. + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, 1, 1)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + } + let report = persister.delete_wallet(w).expect("delete_wallet"); + assert_eq!(report.wallet_id, w); + assert!(report.backup_path.is_some()); + assert!( + report + .rows_removed_per_table + .get("wallet_metadata") + .copied() + .unwrap_or(0) + >= 1 + ); + let conn = persister.lock_conn_for_test(); + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 0); +} + +/// TC-048: deleting a core_transactions row sets `spent_in_txid = NULL` on UTXOs. +#[test] +fn tc048_setnull_on_tx_delete() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + let conn = persister.lock_conn_for_test(); + let txid = [4u8; 32]; + let outpoint = vec![0u8; 36]; + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, 1, NULL, NULL, 0, X'01')", + rusqlite::params![w.as_slice(), &txid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 100, X'00', NULL, 0, 1, ?3)", + rusqlite::params![w.as_slice(), &outpoint, &txid[..]], + ) + .unwrap(); + conn.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![w.as_slice(), &txid[..]], + ) + .unwrap(); + + // The UTXO row must SURVIVE the tx delete — the single-column trigger + // clears `spent_in_txid` only. A future change that turns it into a + // cascading DELETE must fail here, not pass silently. + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![w.as_slice(), &outpoint], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "UTXO row must survive the transaction delete"); + + let (wallet_id, value, account_index, spent_in): (Vec, i64, i64, Option>) = conn + .query_row( + "SELECT wallet_id, value, account_index, spent_in_txid \ + FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![w.as_slice(), &outpoint], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!(wallet_id, w.as_slice(), "wallet_id must be preserved"); + assert_eq!(value, 100, "value must be preserved"); + assert_eq!(account_index, 0, "account_index must be preserved"); + assert!( + spent_in.is_none(), + "spent_in_txid should have been set to NULL" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_hardening_3625.rs b/packages/rs-platform-wallet-storage/tests/sqlite_hardening_3625.rs new file mode 100644 index 00000000000..5df93cc8906 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_hardening_3625.rs @@ -0,0 +1,371 @@ +#![allow(clippy::field_reassign_with_default)] + +//! #3625 structural hardening pass. +//! +//! Native FK rejection (orphan child + mixed-wallet platform addr), +//! multi-account UTXO bucketing (CMT-003/011), identity-key typed-vs-blob +//! consistency (CMT-004), the truncation guards (CMT-012/014), and the +//! compaction-marker-only load gate (CMT-002). + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; + +use dashcore::{Address, Network, OutPoint, TxOut, Txid}; +use key_wallet::Utxo; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::WalletStorageError; +use rusqlite::params; + +/// CMT-001: a child insert without a `wallet_metadata` parent is +/// rejected by the native FK (not a trigger). +#[test] +fn native_fk_rejects_orphan_child() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let res = conn.execute( + "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + VALUES (?1, NULL, ?2, X'00', 0)", + params![[7u8; 32].as_slice(), [9u8; 32].as_slice()], + ); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("FOREIGN KEY"), + "expected FOREIGN KEY failure, got `{err}`" + ); +} + +/// CMT-001: an `identity_keys` row whose `identities` parent does not +/// exist is rejected by the FK to `identities(identity_id)`. +/// +/// V002: `identity_keys` no longer carries `wallet_id`; the FK has +/// moved to `identities(identity_id)` (cascade chain through +/// `wallet_metadata → identities → identity_keys`). +#[test] +fn native_fk_rejects_identity_keys_without_identity() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA1); + ensure_wallet_meta(&persister, &w); + let conn = persister.lock_conn_for_test(); + let res = conn.execute( + "INSERT INTO identity_keys \ + (identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, 0, X'00', X'00', NULL)", + params![[3u8; 32].as_slice()], + ); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("FOREIGN KEY"), + "expected FOREIGN KEY failure on missing identity parent, got `{err}`" + ); +} + +/// CMT-005: a `platform_addresses` entry naming a different wallet than +/// the flush scope fails fast with the typed `WalletIdMismatch`. +#[test] +fn platform_addr_mixed_wallet_rejected() { + use key_wallet::PlatformP2PKHAddress; + let (persister, _tmp, _path) = fresh_persister(); + let scope = wid(0xB1); + let other = wid(0xB2); + ensure_wallet_meta(&persister, &scope); + use dash_sdk::platform::address_sync::AddressFunds; + let mut cs = PlatformWalletChangeSet::default(); + cs.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![PlatformAddressBalanceEntry { + wallet_id: other, + account_index: 0, + address_index: 0, + address: PlatformP2PKHAddress::new([1u8; 20]), + funds: AddressFunds { + nonce: 0, + balance: 0, + }, + }], + ..Default::default() + }); + // Immediate flush mode surfaces the typed error from `store`. + let err = persister + .store(scope, cs) + .expect_err("mixed-wallet must fail"); + assert!( + format!("{err}").contains("wallet id mismatch"), + "expected wallet id mismatch, got `{err}`" + ); +} + +fn p2pkh(byte: u8) -> Address { + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use dashcore::PubkeyHash; + let hash = PubkeyHash::from_byte_array([byte; 20]); + Address::new(Network::Testnet, Payload::PubkeyHash(hash)) +} + +fn make_utxo(addr: &Address, vout: u32, value: u64) -> Utxo { + let outpoint = OutPoint::new( + Txid::from_raw_hash(dashcore::hashes::Hash::all_zeros()), + vout, + ); + let txout = TxOut { + value, + script_pubkey: addr.script_pubkey(), + }; + Utxo::new(outpoint, txout, addr.clone(), 10, false) +} + +/// CMT-003/011: UTXOs resolve their real `account_index` from the +/// derived-address map written earlier in the same transaction, instead +/// of a hardcoded 0. A UTXO on an undeclared address defaults to 0. +#[test] +fn multi_account_utxos_bucket_to_real_account() { + use platform_wallet_storage::sqlite::schema::core_state; + + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC7); + ensure_wallet_meta(&persister, &w); + + let addr_acct5 = p2pkh(0x05); + let addr_acct9 = p2pkh(0x09); + let addr_unknown = p2pkh(0xEE); + + { + let mut conn = persister.lock_conn_for_test(); + // Pre-seed the derived-address map with two distinct accounts. + for (acct, addr) in [(5u32, &addr_acct5), (9u32, &addr_acct9)] { + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, address, derivation_path, used) \ + VALUES (?1, 'standard', ?2, ?3, '0/0', 0)", + params![w.as_slice(), acct as i64, addr.to_string()], + ) + .unwrap(); + } + + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr_acct5, 0, 1000), + make_utxo(&addr_acct9, 1, 2000), + make_utxo(&addr_unknown, 2, 3000), + ], + ..Default::default() + }; + let tx = conn.transaction().unwrap(); + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + assert_eq!( + by_account.get(&5).map(|v| v.len()), + Some(1), + "account 5 should hold exactly one UTXO" + ); + assert_eq!( + by_account.get(&9).map(|v| v.len()), + Some(1), + "account 9 should hold exactly one UTXO" + ); + // The undeclared address falls back to account 0. + assert_eq!( + by_account.get(&0).map(|v| v.len()), + Some(1), + "undeclared address should default to account 0" + ); +} + +/// CMT-014: an out-of-range `birth_height` errors rather than truncating. +#[test] +fn birth_height_overflow_errors_not_truncates() { + use platform_wallet_storage::sqlite::schema::wallet_meta; + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xD1); + { + let conn = persister.lock_conn_for_test(); + // 1<<40 overflows u32 but fits the i64 column. + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", + params![w.as_slice(), 1_099_511_627_776i64], + ) + .unwrap(); + } + let conn = persister.lock_conn_for_test(); + let err = wallet_meta::fetch(&conn, &w).expect_err("overflow must error"); + assert!( + matches!(err, WalletStorageError::IntegerOverflow { .. }), + "expected IntegerOverflow, got {err:?}" + ); +} + +/// CMT-012: an out-of-range stored sync height errors rather than +/// truncating during the monotonic-max read. +#[test] +fn sync_height_overflow_errors_not_truncates() { + use platform_wallet_storage::sqlite::schema::core_state; + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xD2); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, ?2, NULL)", + params![w.as_slice(), 1_099_511_627_776i64], + ) + .unwrap(); + } + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + synced_height: Some(5), + ..Default::default() + }; + let err = core_state::apply(&tx, &w, &cs).expect_err("overflow must error"); + assert!( + matches!(err, WalletStorageError::IntegerOverflow { .. }), + "expected IntegerOverflow, got {err:?}" + ); +} + +/// CMT-004: an `identity_keys` upsert whose entry fields disagree with +/// its map key is rejected, so the typed columns and serialized blob +/// can never describe different rows. +#[test] +fn identity_key_entry_mismatch_rejected() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::prelude::Identifier; + use platform_wallet::changeset::{IdentityKeyEntry, IdentityKeysChangeSet}; + + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xF4); + ensure_wallet_meta(&persister, &w); + + let key_identity = Identifier::from([0xAA; 32]); + let entry_identity = Identifier::from([0xBB; 32]); // deliberately different + let public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }); + let entry = IdentityKeyEntry { + identity_id: entry_identity, + key_id: 0, + public_key, + public_key_hash: [3u8; 20], + wallet_id: Some(w), + derivation_indices: None, + }; + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((key_identity, 0), entry); + + // Immediate flush mode surfaces the typed error from `store`. + let err = persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .expect_err("mismatch must fail"); + assert!( + format!("{err}").contains("disagree"), + "expected identity key entry mismatch, got `{err}`" + ); +} + +/// CMT-007: an asset_locks row whose lifecycle blob disagrees with the +/// typed `account_index` column is rejected at decode time with the +/// typed `AssetLockEntryMismatch` rather than silently mis-bucketing. +#[test] +fn asset_lock_typed_vs_blob_mismatch_rejected() { + use dashcore::Transaction as DashTx; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::AssetLockEntry; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + use platform_wallet_storage::sqlite::schema::{asset_locks, blob}; + + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xF7); + ensure_wallet_meta(&persister, &w); + + // Forge a blob whose internal account_index disagrees with the + // typed column we'll insert it under. + let outpoint = OutPoint::new(Txid::from_raw_hash(dashcore::hashes::Hash::all_zeros()), 7); + let entry = AssetLockEntry { + out_point: outpoint, + transaction: DashTx { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 99, // blob says 99 + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1, + status: AssetLockStatus::Built, + proof: None, + }; + let lifecycle_blob = blob::encode(&entry).unwrap(); + let op_bytes = blob::encode_outpoint(&outpoint); + + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ + VALUES (?1, ?2, 'built', ?3, 0, 1, ?4)", + params![w.as_slice(), &op_bytes[..], 5i64 /* typed says 5, blob says 99 */, lifecycle_blob], + ) + .unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let err = asset_locks::load_state(&conn, &w).expect_err("mismatch must fail"); + assert!( + matches!(err, WalletStorageError::AssetLockEntryMismatch { .. }), + "expected AssetLockEntryMismatch, got {err:?}" + ); +} + +/// CMT-002: a wallet whose only platform-address state is the +/// compaction marker (`last_known_recent_block > 0`) is kept by `load`, +/// not silently dropped. +#[test] +fn load_keeps_compaction_marker_only_wallet() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE3); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO platform_address_sync \ + (wallet_id, sync_height, sync_timestamp, last_known_recent_block) \ + VALUES (?1, 0, 0, 42)", + params![w.as_slice()], + ) + .unwrap(); + } + let state = PlatformWalletPersistence::load(&persister).expect("load"); + assert!( + state.platform_addresses.contains_key(&w), + "compaction-marker-only wallet must be retained" + ); + assert_eq!( + state.platform_addresses[&w].last_known_recent_block, 42, + "marker value should round-trip" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs new file mode 100644 index 00000000000..154d0ab390c --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs @@ -0,0 +1,840 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-040, TC-043, TC-044 — load() reconstructs the wired-up subset. +//! +//! TC-041 / TC-042 (wallets[*].utxos / .unused_asset_locks) are blocked +//! on upstream `Wallet::from_persisted` — the persister stores the data +//! (verified via direct SQL probes) but cannot reconstruct the +//! `Wallet` + `ManagedWalletInfo` pair that `ClientWalletStartState` +//! requires. The unwired fields are listed in +//! `persister::LOAD_UNIMPLEMENTED` and surfaced via a `tracing::warn!` +//! on every `load`. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dash_sdk::platform::address_sync::AddressFunds; +use key_wallet::PlatformP2PKHAddress; +use platform_wallet::changeset::{ + PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet_storage::WalletStorageError; + +fn entry( + wallet_id: [u8; 32], + account_index: u32, + address_index: u32, + byte: u8, +) -> PlatformAddressBalanceEntry { + PlatformAddressBalanceEntry { + wallet_id, + account_index, + address_index, + address: PlatformP2PKHAddress::new([byte; 20]), + funds: AddressFunds { + balance: address_index as u64 * 100, + nonce: address_index, + }, + } +} + +/// TC-040: load() reconstructs platform_addresses per wallet. +#[test] +fn tc040_load_platform_addresses() { + let (persister, _tmp, _path) = fresh_persister(); + let a = wid(0xAA); + let b = wid(0xBB); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + let mut cs_a = PlatformWalletChangeSet::default(); + cs_a.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![entry(a, 0, 0, 0x11), entry(a, 0, 1, 0x12)], + sync_height: Some(10), + ..Default::default() + }); + let mut cs_b = PlatformWalletChangeSet::default(); + cs_b.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![entry(b, 0, 0, 0x21)], + sync_height: Some(20), + ..Default::default() + }); + persister.store(a, cs_a).unwrap(); + persister.store(b, cs_b).unwrap(); + drop(persister); + let tmp_dir = _tmp; + let path = tmp_dir.path().join("wallet.db"); + let p2 = platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(&path), + ) + .unwrap(); + let state = p2.load().unwrap(); + assert_eq!(state.platform_addresses.len(), 2); + assert_eq!(state.platform_addresses[&a].sync_height, 10); + assert_eq!(state.platform_addresses[&b].sync_height, 20); +} + +/// TC-043: non-wired-up sub-areas are written to disk (verified by +/// direct SQL probes) but do not surface in the load result. +/// +/// Constructs non-empty `ContactChangeSet` and `TokenBalanceChangeSet` +/// payloads — `is_empty()` returns false on either, so the buffer +/// flushes them — then asserts both `contacts_sent` and +/// `token_balances` rows are present in SQLite after a reopen, while +/// `ClientStartState.platform_addresses` stays empty for the wallet +/// (no platform-address activity was stored). +#[test] +fn tc043_non_wired_up_persisted_but_not_returned() { + use dpp::prelude::Identifier; + use platform_wallet::changeset::{ + ContactChangeSet, ContactRequestEntry, SentContactRequestKey, TokenBalanceChangeSet, + }; + use platform_wallet::wallet::identity::ContactRequest; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xCC); + let owner = Identifier::from([0x11; 32]); + let recipient = Identifier::from([0x22; 32]); + let token = Identifier::from([0x33; 32]); + ensure_wallet_meta(&persister, &w); + // V002: token_balances FK now targets identities(identity_id), so + // the owner identity must exist before any token-balance row is + // written. contacts_* is still wallet-scoped, so it doesn't need + // an identity row. + common::ensure_identity(&persister, owner.as_bytes(), Some(&w)); + let mut sent_requests = std::collections::BTreeMap::new(); + sent_requests.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: recipient, + }, + ContactRequestEntry { + request: ContactRequest { + sender_id: owner, + recipient_id: recipient, + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_account_label: None, + encrypted_public_key: Vec::new(), + auto_accept_proof: None, + core_height_created_at: 0, + created_at: 0, + }, + }, + ); + let mut balances = std::collections::BTreeMap::new(); + balances.insert((owner, token), 42u64); + let cs = PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + sent_requests, + ..Default::default() + }), + token_balances: Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }), + ..Default::default() + }; + persister.store(w, cs).unwrap(); + drop(persister); + + // Reopen against the same DB and confirm the rows are durable on + // disk + the load result is platform-address-empty for this wallet. + let p2 = platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(&path), + ) + .unwrap(); + let state = p2.load().unwrap(); + assert!( + !state.platform_addresses.contains_key(&w), + "no platform-address activity was stored — wallet must be absent" + ); + drop(p2); + + let conn = common::ro_conn(&path); + let sent: i64 = conn + .query_row( + "SELECT COUNT(*) FROM contacts_sent WHERE wallet_id = ?1 AND owner_id = ?2 AND recipient_id = ?3", + rusqlite::params![w.as_slice(), owner.as_slice(), recipient.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(sent, 1, "contacts_sent row missing after reopen"); + let tokens: i64 = conn + .query_row( + "SELECT COUNT(*) FROM token_balances WHERE identity_id = ?1 AND token_id = ?2", + rusqlite::params![owner.as_slice(), token.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tokens, 1, "token_balances row missing after reopen"); + drop(tmp); +} + +// --------------------------------------------------------------------------- +// P4 — functional load() readers +// --------------------------------------------------------------------------- + +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + ContactChangeSet, ContactRequestEntry, IdentityChangeSet, IdentityEntry, SentContactRequestKey, +}; +use platform_wallet::wallet::identity::{ContactRequest, IdentityStatus}; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +fn identity_entry(id: u8, idx: Option) -> IdentityEntry { + IdentityEntry { + id: Identifier::from([id; 32]), + balance: u64::from(id), + revision: 1, + identity_index: idx, + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Active, + wallet_id: None, + dashpay_profile: None, + dashpay_payments: Default::default(), + } +} + +fn contact_request_entry(sender: u8, recipient: u8) -> ContactRequestEntry { + ContactRequestEntry { + request: ContactRequest { + sender_id: Identifier::from([sender; 32]), + recipient_id: Identifier::from([recipient; 32]), + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_account_label: None, + encrypted_public_key: Vec::new(), + auto_accept_proof: None, + core_height_created_at: 100, + created_at: 0, + }, + } +} + +/// TC-P4-003: identities reader round-trips per wallet, exact equality +/// on `id`s. +/// +/// `persister.load()` no longer surfaces the identities slot (the +/// `ClientStartState` revert dropped it), so this exercises the +/// hardened dormant reader `schema::identities::load_state` directly — +/// keeping its fail-hard behaviour genuinely covered. +#[test] +fn tc_p4_003_load_identities_two_wallets() { + use platform_wallet_storage::sqlite::schema::identities; + use std::collections::BTreeMap; + let (persister, _tmp, path) = fresh_persister(); + let a = wid(0xAA); + let b = wid(0xBB); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + + let mut identities_a: BTreeMap = BTreeMap::new(); + let e_a1 = identity_entry(0x01, Some(0)); + let e_a2 = identity_entry(0x02, Some(1)); + identities_a.insert(e_a1.id, e_a1.clone()); + identities_a.insert(e_a2.id, e_a2.clone()); + let cs_a = PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: identities_a, + removed: Default::default(), + }), + ..Default::default() + }; + + let mut identities_b: BTreeMap = BTreeMap::new(); + let e_b1 = identity_entry(0x10, Some(0)); + identities_b.insert(e_b1.id, e_b1.clone()); + let cs_b = PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: identities_b, + removed: Default::default(), + }), + ..Default::default() + }; + + persister.store(a, cs_a).unwrap(); + persister.store(b, cs_b).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let a_state = identities::load_state(&conn, &a).expect("load_state A"); + let b_state = identities::load_state(&conn, &b).expect("load_state B"); + drop(conn); + + // Both stored under identity_index 0 and 1 — wallet bucket. + let bucket_a = a_state.wallet_identities.get(&a).expect("bucket A"); + assert_eq!(bucket_a.len(), 2); + let mut got_ids: Vec<_> = bucket_a.values().map(|m| m.identity.id()).collect(); + got_ids.sort(); + use dpp::identity::accessors::IdentityGettersV0; + let mut expect_ids = vec![e_a1.id, e_a2.id]; + expect_ids.sort(); + assert_eq!(got_ids, expect_ids); + + let bucket_b = b_state.wallet_identities.get(&b).expect("bucket B"); + assert_eq!(bucket_b.len(), 1); + assert_eq!(bucket_b.values().next().unwrap().identity.id(), e_b1.id); +} + +/// TC-P4-004: contacts round-trip per wallet, exact equality on the +/// contact-request key + entry. +#[test] +fn tc_p4_004_load_contacts_two_wallets() { + use std::collections::BTreeMap; + let (persister, _tmp, path) = fresh_persister(); + let a = wid(0xCA); + let b = wid(0xCB); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + let key_a = SentContactRequestKey { + owner_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from([0x12; 32]), + }; + let entry_a = contact_request_entry(0x11, 0x12); + let mut sent_a = BTreeMap::new(); + sent_a.insert(key_a, entry_a.clone()); + persister + .store( + a, + PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + sent_requests: sent_a, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let key_b = SentContactRequestKey { + owner_id: Identifier::from([0x21; 32]), + recipient_id: Identifier::from([0x22; 32]), + }; + let entry_b = contact_request_entry(0x21, 0x22); + let mut sent_b = BTreeMap::new(); + sent_b.insert(key_b, entry_b.clone()); + persister + .store( + b, + PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + sent_requests: sent_b, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let a_state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &a) + .expect("contacts load_state A"); + let b_state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &b) + .expect("contacts load_state B"); + drop(conn); + let got_a = a_state.sent_requests.get(&key_a).expect("a"); + assert_eq!(got_a.request.sender_id, entry_a.request.sender_id); + assert_eq!( + got_a.request.core_height_created_at, + entry_a.request.core_height_created_at + ); + let got_b = b_state.sent_requests.get(&key_b).expect("b"); + assert_eq!(got_b.request.sender_id, entry_b.request.sender_id); +} + +/// TC-P4-005: asset locks bucketed by (wallet, account, outpoint). +#[test] +fn tc_p4_005_load_asset_locks_bucketed() { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, Txid}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + let (persister, _tmp, path) = fresh_persister(); + let a = wid(0xAA); + let b = wid(0xBB); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + + let mk_entry = |op: OutPoint, account: u32| AssetLockEntry { + out_point: op, + transaction: Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: account, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1000, + status: AssetLockStatus::Built, + proof: None, + }; + let op_a0_1 = OutPoint { + txid: Txid::from_byte_array([0x10; 32]), + vout: 0, + }; + let op_a0_2 = OutPoint { + txid: Txid::from_byte_array([0x11; 32]), + vout: 0, + }; + let op_a5 = OutPoint { + txid: Txid::from_byte_array([0x20; 32]), + vout: 0, + }; + let op_b0 = OutPoint { + txid: Txid::from_byte_array([0x30; 32]), + vout: 0, + }; + let mut locks_a = AssetLockChangeSet::default(); + locks_a.asset_locks.insert(op_a0_1, mk_entry(op_a0_1, 0)); + locks_a.asset_locks.insert(op_a0_2, mk_entry(op_a0_2, 0)); + locks_a.asset_locks.insert(op_a5, mk_entry(op_a5, 5)); + persister + .store( + a, + PlatformWalletChangeSet { + asset_locks: Some(locks_a), + ..Default::default() + }, + ) + .unwrap(); + let mut locks_b = AssetLockChangeSet::default(); + locks_b.asset_locks.insert(op_b0, mk_entry(op_b0, 0)); + persister + .store( + b, + PlatformWalletChangeSet { + asset_locks: Some(locks_b), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let a_buckets = platform_wallet_storage::sqlite::schema::asset_locks::load_state(&conn, &a) + .expect("asset_locks load_state A"); + let b_buckets = platform_wallet_storage::sqlite::schema::asset_locks::load_state(&conn, &b) + .expect("asset_locks load_state B"); + drop(conn); + assert_eq!(a_buckets.len(), 2, "expected 2 account buckets for A"); + assert_eq!(a_buckets[&0].len(), 2); + assert_eq!(a_buckets[&5].len(), 1); + assert_eq!(b_buckets[&0].len(), 1); +} + +/// TC-P4-006: empty wallets emit `wallets_pending_rehydration = N` +/// and `wallets` slot stays empty. +#[tracing_test::traced_test] +#[test] +fn tc_p4_006_pending_rehydration_count() { + let (persister, _tmp, path) = fresh_persister(); + ensure_wallet_meta(&persister, &wid(0x01)); + ensure_wallet_meta(&persister, &wid(0x02)); + ensure_wallet_meta(&persister, &wid(0x03)); + drop(persister); + let p2 = reopen(&path); + let state = p2.load().unwrap(); + assert!(state.wallets.is_empty()); + assert!(logs_contain("wallets_pending_rehydration=3")); + assert!(logs_contain("wallets_rehydrated=0")); +} + +/// TC-P4-007: load() summary carries every counter, including zeros. +#[tracing_test::traced_test] +#[test] +fn tc_p4_007_summary_log_counters() { + let (persister, _tmp, path) = fresh_persister(); + ensure_wallet_meta(&persister, &wid(0x10)); + ensure_wallet_meta(&persister, &wid(0x11)); + drop(persister); + let p2 = reopen(&path); + let _ = p2.load().unwrap(); + for field in [ + "wallets_seen=2", + "addresses_loaded=0", + "wallets_rehydrated=0", + "wallets_pending_rehydration=2", + ] { + assert!(logs_contain(field), "missing structured field: {field}"); + } +} + +/// TC-P4-008: a corrupted blob is a HARD failure. The hardened reader +/// returns `Err` for the corrupt wallet (no silent skip) while the +/// second, intact wallet still decodes cleanly. +#[test] +fn tc_p4_008_corruption_is_hard_error() { + use platform_wallet_storage::sqlite::schema::identities; + use std::collections::BTreeMap; + let (persister, _tmp, path) = fresh_persister(); + let a = wid(0xCA); + let b = wid(0xCB); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + let mut id_a = BTreeMap::new(); + id_a.insert(Identifier::from([0x01; 32]), identity_entry(0x01, Some(0))); + persister + .store( + a, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: id_a, + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + let mut id_b = BTreeMap::new(); + id_b.insert(Identifier::from([0x02; 32]), identity_entry(0x02, Some(0))); + persister + .store( + b, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: id_b, + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + // Truncate A's blob to a single zero byte so bincode bails out. + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE identities SET entry_blob = X'00' WHERE wallet_id = ?1", + rusqlite::params![a.as_slice()], + ) + .unwrap(); + } + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let a_result = identities::load_state(&conn, &a); + let b_state = identities::load_state(&conn, &b).expect("B must decode cleanly"); + drop(conn); + assert!( + matches!(a_result, Err(WalletStorageError::BincodeDecode { .. })), + "corrupt identity blob must be a typed BincodeDecode error, not a \ + silent skip; got {a_result:?}" + ); + assert_eq!(b_state.wallet_identities.get(&b).map(|m| m.len()), Some(1)); +} + +/// TC-P4-008b: `contacts::load_state` is fail-hard. A garbage +/// `entry_blob` yields a typed `BincodeDecode`; a non-32-byte id column +/// yields a typed `BlobDecode`. Neither is silently skipped, and an +/// intact wallet still decodes cleanly. +#[test] +fn tc_p4_008b_contacts_corruption_is_hard_error() { + use platform_wallet_storage::sqlite::schema::contacts; + let (persister, _tmp, path) = fresh_persister(); + let bad_blob = wid(0xD1); + let bad_id = wid(0xD2); + let good = wid(0xD3); + ensure_wallet_meta(&persister, &bad_blob); + ensure_wallet_meta(&persister, &bad_id); + ensure_wallet_meta(&persister, &good); + { + let conn = persister.lock_conn_for_test(); + // bad_blob: well-formed 32-byte ids, undecodable entry_blob. + conn.execute( + "INSERT INTO contacts_sent (wallet_id, owner_id, recipient_id, entry_blob) \ + VALUES (?1, ?2, ?3, X'00')", + rusqlite::params![bad_blob.as_slice(), &[0x11u8; 32][..], &[0x12u8; 32][..]], + ) + .unwrap(); + // bad_id: owner_id is only 10 bytes — fails the 32-byte check. + conn.execute( + "INSERT INTO contacts_sent (wallet_id, owner_id, recipient_id, entry_blob) \ + VALUES (?1, ?2, ?3, X'00')", + rusqlite::params![bad_id.as_slice(), &[0xAAu8; 10][..], &[0x12u8; 32][..]], + ) + .unwrap(); + } + let mut sent_good = std::collections::BTreeMap::new(); + sent_good.insert( + SentContactRequestKey { + owner_id: Identifier::from([0x21; 32]), + recipient_id: Identifier::from([0x22; 32]), + }, + contact_request_entry(0x21, 0x22), + ); + persister + .store( + good, + PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + sent_requests: sent_good, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let blob_result = contacts::load_state_for_test(&conn, &bad_blob); + let id_result = contacts::load_state_for_test(&conn, &bad_id); + let good_state = + contacts::load_state_for_test(&conn, &good).expect("intact wallet must decode"); + drop(conn); + + assert!( + matches!(blob_result, Err(WalletStorageError::BincodeDecode { .. })), + "garbage contacts entry_blob must be a typed BincodeDecode; got {blob_result:?}" + ); + assert!( + matches!(id_result, Err(WalletStorageError::BlobDecode { .. })), + "non-32-byte contacts id column must be a typed BlobDecode; got {id_result:?}" + ); + assert_eq!(good_state.sent_requests.len(), 1); +} + +/// TC-P4-008c: `asset_locks::load_state` is fail-hard. A garbage +/// `lifecycle_blob` yields a typed `BincodeDecode`; a non-36-byte +/// `outpoint` column yields a typed `BlobDecode`. An intact wallet +/// still decodes cleanly. +#[test] +fn tc_p4_008c_asset_locks_corruption_is_hard_error() { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, Txid}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + use platform_wallet_storage::sqlite::schema::asset_locks; + + let (persister, _tmp, path) = fresh_persister(); + let bad_blob = wid(0xE1); + let bad_op = wid(0xE2); + let good = wid(0xE3); + ensure_wallet_meta(&persister, &bad_blob); + ensure_wallet_meta(&persister, &bad_op); + ensure_wallet_meta(&persister, &good); + { + let conn = persister.lock_conn_for_test(); + // bad_blob: valid 36-byte outpoint, undecodable lifecycle_blob. + conn.execute( + "INSERT INTO asset_locks \ + (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ + VALUES (?1, ?2, 'built', 0, 0, 0, X'00')", + rusqlite::params![bad_blob.as_slice(), &[0x01u8; 36][..]], + ) + .unwrap(); + // bad_op: outpoint column is only 4 bytes — fails the 36-byte + // length check before any blob decode is attempted. + conn.execute( + "INSERT INTO asset_locks \ + (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ + VALUES (?1, ?2, 'built', 0, 0, 0, X'00')", + rusqlite::params![bad_op.as_slice(), &[0x01u8; 4][..]], + ) + .unwrap(); + } + let op_good = OutPoint { + txid: Txid::from_byte_array([0x40; 32]), + vout: 0, + }; + let mut locks_good = AssetLockChangeSet::default(); + locks_good.asset_locks.insert( + op_good, + AssetLockEntry { + out_point: op_good, + transaction: Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1000, + status: AssetLockStatus::Built, + proof: None, + }, + ); + persister + .store( + good, + PlatformWalletChangeSet { + asset_locks: Some(locks_good), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let blob_result = asset_locks::load_state(&conn, &bad_blob); + let op_result = asset_locks::load_state(&conn, &bad_op); + let good_state = asset_locks::load_state(&conn, &good).expect("intact wallet must decode"); + drop(conn); + + assert!( + matches!(blob_result, Err(WalletStorageError::BincodeDecode { .. })), + "garbage asset_locks lifecycle_blob must be a typed BincodeDecode; got {blob_result:?}" + ); + assert!( + matches!(op_result, Err(WalletStorageError::BlobDecode { .. })), + "non-36-byte asset_locks outpoint must be a typed BlobDecode; got {op_result:?}" + ); + assert_eq!(good_state[&0].len(), 1); +} + +/// TC-P4-008d: `wallet_meta::list_ids` is fail-hard on a malformed +/// stored `wallet_id`. This is the code path where a non-32-byte id +/// actually surfaces (the per-area `load_state` readers take a typed +/// `&WalletId`, so the length check belongs here). A 10-byte +/// `wallet_metadata.wallet_id` yields a typed `InvalidWalletIdLength`. +#[test] +fn tc_p4_008d_list_ids_rejects_non_32_byte_wallet_id() { + use platform_wallet_storage::sqlite::schema::wallet_meta; + let (persister, _tmp, path) = fresh_persister(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + rusqlite::params![&[0xAAu8; 10][..]], + ) + .unwrap(); + } + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let result = wallet_meta::list_ids(&conn); + drop(conn); + assert!( + matches!( + result, + Err(WalletStorageError::InvalidWalletIdLength { actual: 10 }) + ), + "non-32-byte stored wallet_id must be a typed InvalidWalletIdLength {{ actual: 10 }}; \ + got {result:?}" + ); +} + +/// TC-P4-012: `load()` query cost is bounded per wallet. +/// +/// `load()` now drives the platform-address reader off +/// `wallet_meta::list_ids` and issues a fixed, small number of +/// statements per listed wallet (the dedup collapse traded the old +/// constant-query bulk scans for the fail-hard per-wallet readers). +/// This pins the per-wallet statement count so a future regression +/// that fans out into an unbounded per-row round trip is caught. +/// +/// Verified by enabling `sqlite3_trace_v2` on the persister's +/// connection, counting `Stmt` events for the duration of one +/// `load()`. `serial_test::serial` because the trace counter is a +/// process-wide `AtomicUsize` (`Connection::trace_v2`'s callback must +/// be a `fn`, not a `Fn`). +#[test] +#[serial_test::serial] +fn tc_p4_012_load_query_count_bounded() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static COUNTER: AtomicUsize = AtomicUsize::new(0); + fn cb(ev: rusqlite::trace::TraceEvent<'_>) { + if let rusqlite::trace::TraceEvent::Stmt(_, _) = ev { + COUNTER.fetch_add(1, Ordering::Relaxed); + } + } + + fn count_load_queries(persister: &common::SqlitePersister) -> usize { + // Hold the conn briefly to install the trace, then drop the + // guard before calling load() (load takes its own lock). + { + let conn = persister.lock_conn_for_test(); + conn.trace_v2( + rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT, + Some(cb), + ); + } + COUNTER.store(0, Ordering::Relaxed); + persister.load().expect("load"); + let n = COUNTER.load(Ordering::Relaxed); + // Disable trace so other tests don't accidentally inherit it. + { + let conn = persister.lock_conn_for_test(); + conn.trace_v2(rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT, None); + } + n + } + + fn seed_wallets(persister: &common::SqlitePersister, n: usize) { + for i in 0..n { + let id = wid(0xC0 + i as u8); + ensure_wallet_meta(persister, &id); + let mut cs = PlatformWalletChangeSet::default(); + cs.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![entry(id, 0, 0, 0xA0 + i as u8)], + sync_height: Some(1), + ..Default::default() + }); + persister.store(id, cs).unwrap(); + } + } + + let (p1, _tmp1, _path1) = fresh_persister(); + seed_wallets(&p1, 1); + let count_one = count_load_queries(&p1); + + let (p10, _tmp10, _path10) = fresh_persister(); + seed_wallets(&p10, 10); + let count_ten = count_load_queries(&p10); + + // Per wallet `load()` issues exactly two statements + // (`platform_addrs::load_state` sync header + `count_per_wallet`), + // plus one shared `wallet_meta::list_ids`: total = 1 + 2*N. Pinning + // the per-wallet delta to 2 catches any unbounded per-row fan-out. + let per_wallet = (count_ten - count_one) as f64 / 9.0; + assert_eq!( + per_wallet, 2.0, + "load() must issue a fixed 2 statements per wallet \ + (N=1 → {count_one}, N=10 → {count_ten}, per-wallet → {per_wallet})" + ); + assert_eq!( + count_one, 3, + "load() with one wallet must be 1 (list_ids) + 2 (per-wallet) = 3, got {count_one}" + ); +} + +/// TC-P4-010: empty database → defaults, ZERO warnings. +#[tracing_test::traced_test] +#[test] +fn tc_p4_010_empty_db_default_state() { + let (persister, _tmp, path) = fresh_persister(); + drop(persister); + let p2 = reopen(&path); + let state = p2.load().unwrap(); + assert!(state.is_empty()); + assert!(logs_contain("wallets_seen=0")); + assert!(logs_contain("wallets_pending_rehydration=0")); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs new file mode 100644 index 00000000000..ba1cc3e3029 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -0,0 +1,227 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-025..TC-030, TC-028, TC-044 — migration discovery and reach. + +mod common; + +use common::fresh_persister; +use platform_wallet_storage::sqlite::migrations as mig; + +/// TC-025: every embedded migration corresponds to a file in `migrations/`. +#[test] +fn tc025_embedded_migrations_match_files() { + let embedded = mig::embedded_migrations(); + assert!(!embedded.is_empty(), "no migrations embedded"); + let crate_root = env!("CARGO_MANIFEST_DIR"); + let on_disk: Vec<_> = std::fs::read_dir(format!("{crate_root}/migrations")) + .expect("read migrations dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with('V') && n.ends_with(".rs")) + .collect(); + assert_eq!( + embedded.len(), + on_disk.len(), + "embedded vs on-disk count mismatch: {embedded:?} vs {on_disk:?}" + ); + for (v, name) in &embedded { + let expected_padded = format!("V{:03}__{}.rs", v, name); + let expected_plain = format!("V{}__{}.rs", v, name); + assert!( + on_disk + .iter() + .any(|f| f == &expected_padded || f == &expected_plain), + "no on-disk file for migration V{v} {name} \ + (expected {expected_padded} or {expected_plain})" + ); + } +} + +/// TC-026: fresh DB ends at latest schema version. +#[test] +fn tc026_fresh_db_at_latest() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let max: Option = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .unwrap(); + let highest_embedded = mig::embedded_migrations() + .iter() + .map(|(v, _)| *v as i64) + .max() + .unwrap(); + assert_eq!(max, Some(highest_embedded)); +} + +/// TC-027: every declared table is creatable and accepts a minimal row +/// (parent first, then children). +#[test] +fn tc027_smoke_insert_every_table() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + use rusqlite::params; + let wallet_id = [42u8; 32]; + + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .unwrap(); + let identity_id = [7u8; 32]; + conn.execute( + "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + VALUES (?1, NULL, ?2, X'01', 0)", + params![wallet_id.as_slice(), identity_id.as_slice()], + ) + .unwrap(); + let outpoint = vec![0u8; 36]; + let txid = vec![0u8; 32]; + let cases: &[(&str, &str, &[&dyn rusqlite::ToSql])] = &[ + ( + "account_registrations", + "INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'Standard', 0, X'00')", + &[&wallet_id.as_slice()], + ), + ( + "account_address_pools", + "INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'Standard', 0, 'External', X'00')", + &[&wallet_id.as_slice()], + ), + ( + "core_transactions", + "INSERT INTO core_transactions (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) VALUES (?1, ?2, NULL, NULL, NULL, 0, X'00')", + &[&wallet_id.as_slice(), &txid], + ), + ( + "core_utxos", + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) VALUES (?1, ?2, 0, X'00', NULL, 0, 0, NULL)", + &[&wallet_id.as_slice(), &outpoint], + ), + ( + "core_instant_locks", + "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, X'00')", + &[&wallet_id.as_slice(), &txid], + ), + ( + "core_derived_addresses", + "INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, address, derivation_path, used) VALUES (?1, 'Standard', 0, 'addr', '', 0)", + &[&wallet_id.as_slice()], + ), + ( + "core_sync_state", + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, NULL, NULL)", + &[&wallet_id.as_slice()], + ), + ( + "identity_keys", + // V002: identity_keys drops the wallet_id column; the + // FK now targets identities(identity_id). + "INSERT INTO identity_keys (identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) VALUES (?1, 0, X'00', X'00', NULL)", + &[&identity_id.as_slice()], + ), + ( + "contacts_sent", + "INSERT INTO contacts_sent (wallet_id, owner_id, recipient_id, entry_blob) VALUES (?1, ?2, ?3, X'00')", + &[&wallet_id.as_slice(), &identity_id.as_slice(), &[1u8; 32].as_slice()], + ), + ( + "contacts_recv", + "INSERT INTO contacts_recv (wallet_id, owner_id, sender_id, entry_blob) VALUES (?1, ?2, ?3, X'00')", + &[&wallet_id.as_slice(), &identity_id.as_slice(), &[2u8; 32].as_slice()], + ), + ( + "contacts_established", + "INSERT INTO contacts_established (wallet_id, owner_id, contact_id, entry_blob) VALUES (?1, ?2, ?3, X'00')", + &[&wallet_id.as_slice(), &identity_id.as_slice(), &[3u8; 32].as_slice()], + ), + ( + "platform_addresses", + "INSERT INTO platform_addresses (wallet_id, account_index, address_index, address, balance, nonce) VALUES (?1, 0, 0, X'0000000000000000000000000000000000000000', 0, 0)", + &[&wallet_id.as_slice()], + ), + ( + "platform_address_sync", + "INSERT INTO platform_address_sync (wallet_id, sync_height, sync_timestamp, last_known_recent_block) VALUES (?1, 0, 0, 0)", + &[&wallet_id.as_slice()], + ), + ( + "asset_locks", + "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'built', 0, 0, 0, X'00')", + &[&wallet_id.as_slice(), &outpoint], + ), + ( + "token_balances", + // V002: token_balances PK is (identity_id, token_id); + // wallet_id column is gone. + "INSERT INTO token_balances (identity_id, token_id, balance, updated_at) VALUES (?1, ?2, 0, 0)", + &[&identity_id.as_slice(), &[5u8; 32].as_slice()], + ), + ( + "dashpay_profiles", + // V002: dashpay_profiles keyed by identity_id only. + "INSERT INTO dashpay_profiles (identity_id, profile_blob) VALUES (?1, X'00')", + &[&identity_id.as_slice()], + ), + ( + "dashpay_payments_overlay", + // V002: dashpay_payments_overlay keyed by (identity_id, payment_id). + "INSERT INTO dashpay_payments_overlay (identity_id, payment_id, overlay_blob) VALUES (?1, 'pay1', X'00')", + &[&identity_id.as_slice()], + ), + ]; + use platform_wallet_storage::sqlite::schema::{count_rows_for_wallet_sql, PER_WALLET_TABLES}; + let scope_for = |name: &str| { + PER_WALLET_TABLES + .iter() + .find(|(t, _)| *t == name) + .map(|(_, s)| *s) + .expect("table is in PER_WALLET_TABLES") + }; + for (table, sql, params) in cases { + conn.execute(sql, *params).expect(table); + let n: i64 = conn + .query_row( + &count_rows_for_wallet_sql(table, scope_for(table)), + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert!(n >= 1, "{table} insert did not land"); + } +} + +/// TC-028: re-open is idempotent. +#[test] +fn tc028_idempotent_reopen() { + let (persister, tmp, path) = fresh_persister(); + drop(persister); + let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&path); + let _p2 = platform_wallet_storage::SqlitePersister::open(cfg).expect("reopen"); + drop(tmp); +} + +/// TC-029: append-only migration hash. +/// +/// The hash is computed at runtime from the embedded list. Because this +/// test belongs to the migration drift policy, we assert the list is +/// non-empty and the hash is stable across successive calls — not a +/// pinned value (which would force a churn on every committed migration). +#[test] +fn tc029_migration_fingerprint_stable() { + let a = mig::embedded_migrations_fingerprint(); + let b = mig::embedded_migrations_fingerprint(); + assert_eq!(a, b); + assert!(!mig::embedded_migrations().is_empty()); +} + +/// TC-044: load() on empty post-migrate DB is empty. +#[test] +fn tc044_load_empty_is_empty() { + let (persister, _tmp, _path) = fresh_persister(); + let state = platform_wallet::changeset::PlatformWalletPersistence::load(&persister).unwrap(); + assert!(state.is_empty()); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs b/packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs new file mode 100644 index 00000000000..32fd30e91b8 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs @@ -0,0 +1,150 @@ +#![allow(clippy::field_reassign_with_default)] + +//! ATOM-013 (A-8) — `open()` runs `PRAGMA integrity_check` on a +//! pre-existing DB BEFORE migrations alter it, so bit-rot / escaped- +//! WAL corruption surfaces as the typed `IntegrityCheckFailed` instead +//! of being silently migrated (and snapshotted into the pre-migration +//! auto-backup, defeating rollback). + +mod common; + +use std::fs::OpenOptions; +use std::io::{Read, Seek, SeekFrom, Write}; + +use common::fresh_persister; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +/// Deliberately corrupt the SQLite file at `path` by flipping bytes +/// well past the 100-byte header (where the schema/btree pages live). +/// We avoid the header so the file still opens as SQLite — the +/// integrity_check catches the structural rot. +fn corrupt_btree_pages(path: &std::path::Path) { + let mut f = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .expect("open db for corruption"); + let len = f.metadata().unwrap().len(); + assert!( + len >= 8192, + "need at least two full pages to corrupt page 2; got {len} bytes" + ); + // Read page 2 (bytes 4096..8192), flip every other byte, write back. + f.seek(SeekFrom::Start(4096)).unwrap(); + let mut buf = vec![0u8; 4096]; + f.read_exact(&mut buf).unwrap(); + for b in buf.iter_mut().step_by(2) { + *b ^= 0xFF; + } + f.seek(SeekFrom::Start(4096)).unwrap(); + f.write_all(&buf).unwrap(); + f.sync_all().unwrap(); +} + +/// ATOM-013: opening a corrupt DB returns `IntegrityCheckFailed` +/// instead of running migrations against it. +#[test] +fn atom_013_open_rejects_corrupt_db() { + let (persister, tmp, path) = fresh_persister(); + // Add at least one user row so there's content to corrupt past the header. + { + use rusqlite::params; + let conn = persister.lock_conn_for_test(); + // Push the DB past a few pages with a chunky meta row. + for i in 0..20u32 { + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", + params![vec![i as u8; 32].as_slice(), i as i64], + ) + .unwrap(); + } + } + drop(persister); + + corrupt_btree_pages(&path); + + let cfg = SqlitePersisterConfig::new(&path); + let res = SqlitePersister::open(cfg); + let err = match res { + Ok(_) => panic!("open must reject corrupt DB"), + Err(e) => e, + }; + assert!( + matches!(err, WalletStorageError::IntegrityCheckFailed { .. }), + "expected IntegrityCheckFailed, got {err:?}" + ); + drop(tmp); +} + +/// Flip bytes inside pages 2 AND 3 so SQLite's `PRAGMA integrity_check` +/// has multiple problems to report. This widens the surface beyond the +/// single-row "ok" case so the multi-row collection path is exercised. +fn corrupt_multiple_pages(path: &std::path::Path) { + let mut f = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .expect("open db for multi-page corruption"); + let len = f.metadata().unwrap().len(); + assert!(len > 8192, "expected at least two full pages"); + for page_start in [4096u64, 8192] { + f.seek(SeekFrom::Start(page_start)).unwrap(); + let mut buf = vec![0u8; 4096]; + f.read_exact(&mut buf).unwrap(); + for b in buf.iter_mut().step_by(2) { + *b ^= 0xFF; + } + f.seek(SeekFrom::Start(page_start)).unwrap(); + f.write_all(&buf).unwrap(); + } + f.sync_all().unwrap(); +} + +/// TC-CODE-016-a: a multi-problem DB surfaces every diagnostic line in +/// `IntegrityCheckFailed::report`. Pre-fix the helper used `query_row` +/// and silently dropped every row past the first. We assert the report +/// is non-empty and not the truncated single-row "ok" sentinel; we +/// don't bind to a fixed line count because SQLite's exact diagnostic +/// shape isn't stable across builds. +#[test] +fn tc_code_016_a_integrity_report_collects_all_rows() { + let (persister, tmp, path) = fresh_persister(); + { + use rusqlite::params; + let conn = persister.lock_conn_for_test(); + for i in 0..40u32 { + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", + params![vec![i as u8; 32].as_slice(), i as i64], + ) + .unwrap(); + } + } + drop(persister); + + corrupt_multiple_pages(&path); + + let cfg = SqlitePersisterConfig::new(&path); + let err = match SqlitePersister::open(cfg) { + Ok(_) => panic!("open must reject multi-page corrupt DB"), + Err(e) => e, + }; + let report = match err { + WalletStorageError::IntegrityCheckFailed { report } => report, + other => panic!("expected IntegrityCheckFailed, got {other:?}"), + }; + assert!(!report.is_empty(), "report must be non-empty"); + assert_ne!( + report.trim(), + "ok", + "report must NOT be the healthy sentinel" + ); + // Source-level regression: the helper must use `query_map`, not + // `query_row`, so multi-row reports are preserved. + let helper_src = include_str!("../src/sqlite/backup.rs"); + assert!( + helper_src.contains("PRAGMA integrity_check") && helper_src.contains("query_map"), + "run_integrity_check must use query_map (not query_row) to collect every diagnostic row" + ); + drop(tmp); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_open_version_gate.rs b/packages/rs-platform-wallet-storage/tests/sqlite_open_version_gate.rs new file mode 100644 index 00000000000..c3f102d3e99 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_open_version_gate.rs @@ -0,0 +1,34 @@ +//! CMT-005 — `SqlitePersister::open` must refuse a database whose +//! `refinery_schema_history` MAX(version) exceeds the embedded max. +//! Symmetric with `restore_from`: a forged forward-version row that +//! older binary would otherwise migration::run() no-op past gets +//! caught at open time. + +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +#[test] +fn open_rejects_forward_schema_version() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("wallet.db"); + + // First open to run the embedded migrations. + { + let _p = SqlitePersister::open(SqlitePersisterConfig::new(&db_path)).expect("open"); + } + // Forge a row claiming a future migration was already applied. + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ + VALUES (?1, 'future', '', '0')", + rusqlite::params![1_000_000i64], + ) + .unwrap(); + } + // Re-open must fail with SchemaVersionUnsupported. + match SqlitePersister::open(SqlitePersisterConfig::new(&db_path)) { + Err(WalletStorageError::SchemaVersionUnsupported { .. }) => {} + Err(other) => panic!("expected SchemaVersionUnsupported, got {other:?}"), + Ok(_) => panic!("forward-version DB must be refused"), + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_permissions.rs b/packages/rs-platform-wallet-storage/tests/sqlite_permissions.rs new file mode 100644 index 00000000000..d010ee94f0c --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_permissions.rs @@ -0,0 +1,158 @@ +//! CMT-003 / CMT-004 — owner-only permissions on the live DB AND its +//! `-wal` / `-shm` sidecars. SQLite's default WAL journal mode keeps +//! recent committed pages in the sidecars, so leaving them at the +//! process umask leaks wallet state on multi-user hosts. + +#![cfg(unix)] +#![allow(clippy::field_reassign_with_default)] + +mod common; + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::os::unix::fs::PermissionsExt; + +use common::{ensure_wallet_meta, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +#[test] +fn wal_and_shm_sidecars_are_chmodded_0o600() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("wallet.db"); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&db_path)).expect("open"); + + // Seed the parent row and trigger a write so SQLite materializes + // the WAL/SHM siblings. + let w = wid(0xA1); + ensure_wallet_meta(&persister, &w); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(5), + last_processed_height: Some(5), + ..Default::default() + }); + persister.store(w, cs).expect("store"); + + let wal = tmp.path().join("wallet.db-wal"); + let shm = tmp.path().join("wallet.db-shm"); + assert!(wal.exists(), "WAL sibling should exist after a write"); + assert!(shm.exists(), "SHM sibling should exist after a write"); + + // Loosen sidecar perms behind the helper's back, then re-apply. + // This isolates the sidecar-chmod codepath from whatever umask the + // test runner happened to inherit. + for path in [&wal, &shm] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666)).unwrap(); + } + platform_wallet_storage::sqlite::util::permissions::apply_secure_permissions(&db_path) + .expect("apply_secure_permissions"); + + for path in [&db_path, &wal, &shm] { + let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, + 0o600, + "expected 0o600 on {} after apply_secure_permissions, got {:o}", + path.display(), + mode + ); + } +} + +/// TC-CODE-011-a: `apply_secure_permissions` survives a non-ASCII DB +/// filename whose bytes round-trip through `OsString` (the codepath +/// builds sidecar names via `OsString::push`, not `format!` over a +/// lossy `String`). The chosen prefix `ÿþ` (`U+00FF U+00FE`, UTF-8 +/// bytes `c3 bf c3 be`) is multi-byte non-ASCII that both Linux and +/// macOS APFS accept — APFS rejects raw non-UTF-8 with `EILSEQ`, so +/// the bytes here are deliberately valid UTF-8 while still exercising +/// the `OsString`-end-to-end path the pre-fix `to_string_lossy()` would +/// have mangled into the wrong sibling names. +#[test] +fn tc_code_011_a_non_ascii_db_path_sidecars_chmodded() { + let tmp = tempfile::tempdir().unwrap(); + // Valid-UTF-8 multi-byte prefix `ÿþ` + `.db` / `.db-wal` / `.db-shm`. + // We still go through `OsString::from_vec` to mirror the production + // codepath's `OsStr`/`OsString` API surface end-to-end. + let prefix: &[u8] = &[0xC3, 0xBF, 0xC3, 0xBE]; // "ÿþ" in UTF-8 + debug_assert_eq!(std::str::from_utf8(prefix).unwrap(), "ÿþ"); + let mk = |suffix: &[u8]| -> OsString { + let mut v = prefix.to_vec(); + v.extend_from_slice(suffix); + OsString::from_vec(v) + }; + let db_name = mk(b".db"); + let wal_name = mk(b".db-wal"); + let shm_name = mk(b".db-shm"); + let db_path = tmp.path().join(&db_name); + let wal = tmp.path().join(&wal_name); + let shm = tmp.path().join(&shm_name); + // Plant the trio with permissive perms so the chmod is observable. + for p in [&db_path, &wal, &shm] { + std::fs::write(p, b"x").unwrap(); + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o666)).unwrap(); + } + + platform_wallet_storage::sqlite::util::permissions::apply_secure_permissions(&db_path) + .expect("apply_secure_permissions"); + + for p in [&db_path, &wal, &shm] { + let mode = std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, + 0o600, + "expected 0o600 on non-ASCII path {} after apply_secure_permissions, got {:o}", + p.display(), + mode + ); + } +} + +/// TC-CODE-011-b: `apply_secure_permissions` is a no-op (Ok) when the +/// sidecars don't exist. The `set_permissions` call sees +/// `ErrorKind::NotFound` and swallows it — no `exists()` gate, no +/// race window. +#[test] +fn tc_code_011_b_no_sidecars_is_ok() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("solo.db"); + std::fs::write(&db_path, b"x").unwrap(); + // No -wal / -shm planted on purpose. + platform_wallet_storage::sqlite::util::permissions::apply_secure_permissions(&db_path) + .expect("apply_secure_permissions on solo DB must be Ok"); + let mode = std::fs::metadata(&db_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + // Source-level regression: the helper must NOT contain `exists(` + // anywhere in its sibling-chmod path. + let src = include_str!("../src/sqlite/util/permissions.rs"); + assert!( + !src.contains("sibling.exists("), + "permissions.rs must not pre-gate set_permissions on sibling.exists() (TOCTOU)" + ); + assert!( + !src.contains(".to_string_lossy().to_string()"), + "permissions.rs must not build sibling paths via .to_string_lossy().to_string() (loses non-UTF-8 bytes)" + ); +} + +/// TC-CODE-011-c: the same OsString + NotFound-swallow pattern in +/// `backup.rs`'s WAL/SHM-unlink loop (DRY motif). +#[test] +fn tc_code_011_c_backup_wal_shm_unlink_no_lossy_no_exists_gate() { + let src = include_str!("../src/sqlite/backup.rs"); + // The unlink loop now uses OsString::push, not to_string_lossy. + // We can't structurally diff the loop, but the file must not + // contain the lossy pattern on the sidecar build path. + assert!( + !src.contains("s.to_string_lossy().to_string()"), + "backup.rs must not build sibling paths via to_string_lossy().to_string()" + ); + // And remove_file must not be gated on sibling.exists(). + assert!( + !src.contains("sibling.exists()"), + "backup.rs WAL/SHM-unlink must not pre-gate remove_file on sibling.exists() (TOCTOU)" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs new file mode 100644 index 00000000000..8faf5fc3f3b --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -0,0 +1,581 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Per-sub-changeset round-trip tests. +//! +//! Now that `platform-wallet`'s `serde` feature is active, every +//! changeset blob is a single bincode-serde payload — these tests +//! store a non-trivial entry, reopen the persister, decode the blob, +//! and assert structural equality (where the type allows) or +//! field-level equality (where it doesn't, e.g. `TransactionRecord` +//! which is `Debug + Clone` only upstream). +//! +//! TC-001 (CoreChangeSet records) is exercised through the trait +//! method in `sqlite_buffer_semantics.rs::tc001_get_core_tx_record_roundtrip`. +//! TC-015 (multi-wallet coexistence) lives there too. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::Network; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet_storage::{ + JournalMode, SqlitePersister, SqlitePersisterConfig, Synchronous, WalletStorageError, +}; + +/// TC-005: sync heights round-trip with monotonic-max merge. +#[test] +fn tc005_sync_heights_roundtrip() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xF0); + ensure_wallet_meta(&persister, &w); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + last_processed_height: Some(100), + synced_height: Some(95), + ..Default::default() + }); + persister.store(w, cs).unwrap(); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + last_processed_height: Some(120), + synced_height: Some(100), + ..Default::default() + }); + persister.store(w, cs).unwrap(); + let conn = persister.lock_conn_for_test(); + let (lp, sy): (i64, i64) = conn + .query_row( + "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(lp, 120); + assert_eq!(sy, 100); +} + +/// TC-013: wallet_metadata round-trip. +#[test] +fn tc013_wallet_metadata_roundtrip() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xF1); + ensure_wallet_meta(&persister, &w); + let cs = PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + birth_height: 12345, + }), + ..Default::default() + }; + persister.store(w, cs).unwrap(); + let conn = persister.lock_conn_for_test(); + let (network, birth_height): (String, i64) = conn + .query_row( + "SELECT network, birth_height FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(network, "testnet"); + assert_eq!(birth_height, 12345); +} + +/// TC-CODE-029-1: journal_mode=Memory is rejected at open with a typed +/// `ConfigInvalid` error and the DB is not created. +#[test] +fn tc_code_029_1_journal_mode_memory_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let mut cfg = SqlitePersisterConfig::new(&path); + cfg.journal_mode = JournalMode::Memory; + let err = SqlitePersister::open(cfg); + let matched = matches!(err.as_ref(), Err(WalletStorageError::ConfigInvalid { .. })); + assert!( + matched, + "expected ConfigInvalid for journal_mode=Memory, got error = {:?}", + err.as_ref().err() + ); + assert!( + !path.exists(), + "DB should not be created when config is invalid" + ); +} + +/// TC-CODE-029-2: journal_mode=Off is rejected at open with a typed +/// `ConfigInvalid` error and the DB is not created. +#[test] +fn tc_code_029_2_journal_mode_off_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let mut cfg = SqlitePersisterConfig::new(&path); + cfg.journal_mode = JournalMode::Off; + let err = SqlitePersister::open(cfg); + let matched = matches!(err.as_ref(), Err(WalletStorageError::ConfigInvalid { .. })); + assert!( + matched, + "expected ConfigInvalid for journal_mode=Off, got error = {:?}", + err.as_ref().err() + ); + assert!( + !path.exists(), + "DB should not be created when config is invalid" + ); +} + +/// TC-CODE-029-3: busy_timeout=0 opens successfully but emits a +/// tracing::warn so operators can spot the footgun in logs. +#[test] +#[tracing_test::traced_test] +fn tc_code_029_3_busy_timeout_zero_warns() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let mut cfg = SqlitePersisterConfig::new(&path); + cfg.busy_timeout = std::time::Duration::ZERO; + let p = SqlitePersister::open(cfg).expect("open should succeed with busy_timeout=0"); + drop(p); + assert!( + logs_contain("busy_timeout=0"), + "expected a busy_timeout=0 warning in captured logs" + ); +} + +/// TC-079: synchronous=Off is rejected at open with a typed error. +#[test] +fn tc079_synchronous_off_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let mut cfg = SqlitePersisterConfig::new(&path); + cfg.synchronous = Synchronous::Off; + let err = SqlitePersister::open(cfg); + let matched = matches!(err.as_ref(), Err(WalletStorageError::ConfigInvalid { .. })); + assert!( + matched, + "expected ConfigInvalid, got error = {:?}", + err.as_ref().err() + ); + assert!( + !path.exists(), + "DB should not be created when config is invalid" + ); +} + +/// TC-080: SqlitePersisterConfig::new yields sensible defaults. +#[test] +fn tc080_config_defaults() { + let cfg = SqlitePersisterConfig::new("/tmp/some.db"); + assert!(matches!( + cfg.flush_mode, + platform_wallet_storage::FlushMode::Immediate + )); + assert_eq!(cfg.busy_timeout, std::time::Duration::from_secs(5)); + assert!(matches!( + cfg.journal_mode, + platform_wallet_storage::JournalMode::Wal + )); + assert!(matches!(cfg.synchronous, Synchronous::Normal)); + assert!(cfg.auto_backup_dir.is_some()); +} + +/// TC-081: LockPoisoned round-trips into PersistenceError::LockPoisoned. +#[test] +fn tc081_lock_poisoned_mapping() { + use platform_wallet::changeset::PersistenceError; + let err = WalletStorageError::LockPoisoned; + let mapped: PersistenceError = err.into(); + assert!(matches!(mapped, PersistenceError::LockPoisoned)); +} + +/// TC-007: IdentityKeysChangeSet stores public-only material that +/// round-trips through `identity_keys.public_key_blob`. +#[test] +fn tc007_identity_key_entry_roundtrip() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::prelude::Identifier; + use platform_wallet::changeset::{ + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, + }; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xF7); + ensure_wallet_meta(&persister, &w); + + let identity_id = Identifier::from([0xAA; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + identity_id.as_slice().try_into().unwrap(), + ) + .unwrap(); + + let public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }); + let entry = IdentityKeyEntry { + identity_id, + key_id: 7, + public_key: public_key.clone(), + public_key_hash: [3u8; 20], + wallet_id: Some(w), + derivation_indices: Some(IdentityKeyDerivationIndices { + identity_index: 1, + key_index: 2, + }), + }; + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((identity_id, 7), entry.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p2.lock_conn_for_test(); + // V002: identity_keys is keyed by (identity_id, key_id); the + // wallet_id column was dropped. + let blob_bytes: Vec = conn + .query_row( + "SELECT public_key_blob FROM identity_keys WHERE identity_id = ?1 AND key_id = ?2", + rusqlite::params![identity_id.as_slice(), 7i64], + |row| row.get(0), + ) + .unwrap(); + let decoded = + platform_wallet_storage::sqlite::schema::identity_keys::decode_entry(&blob_bytes).unwrap(); + assert_eq!(decoded, entry); + // The load-bearing NFR-10 check is `tests/secrets_scan.rs`, + // which greps every file under `src/sqlite/schema/` and + // `migrations/` for forbidden secret-material substrings — + // bincode wire bytes carry no field names, so any runtime + // substring scan against the blob would be a false-confidence + // smoke test. + drop(tmp); +} + +/// TC-009: PlatformAddressChangeSet round-trips through +/// `platform_addresses`. The typed columns (account_index, +/// address_index, address, balance, nonce) carry the entire +/// `PlatformAddressBalanceEntry` shape — no blob column needed for +/// this table, but we exercise the schema writer + a direct probe. +#[test] +fn tc009_platform_address_roundtrip() { + use dash_sdk::platform::address_sync::AddressFunds; + use key_wallet::PlatformP2PKHAddress; + use platform_wallet::changeset::{PlatformAddressBalanceEntry, PlatformAddressChangeSet}; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xF8); + ensure_wallet_meta(&persister, &w); + + let addr1 = PlatformP2PKHAddress::new([0x11; 20]); + let addr2 = PlatformP2PKHAddress::new([0x22; 20]); + let entries = vec![ + PlatformAddressBalanceEntry { + wallet_id: w, + account_index: 0, + address_index: 0, + address: addr1, + funds: AddressFunds { + nonce: 1, + balance: 500, + }, + }, + PlatformAddressBalanceEntry { + wallet_id: w, + account_index: 0, + address_index: 1, + address: addr2, + funds: AddressFunds { + nonce: 2, + balance: 1500, + }, + }, + ]; + persister + .store( + w, + PlatformWalletChangeSet { + platform_addresses: Some(PlatformAddressChangeSet { + addresses: entries.clone(), + sync_height: Some(99), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let rows = platform_wallet_storage::sqlite::schema::platform_addrs::list_per_wallet( + &p2.lock_conn_for_test(), + &w, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].address, addr1); + assert_eq!(rows[0].funds.balance, 500); + assert_eq!(rows[0].funds.nonce, 1); + assert_eq!(rows[1].address, addr2); + assert_eq!(rows[1].funds.balance, 1500); + drop(tmp); +} + +/// TC-014: AccountRegistrationEntry round-trips through +/// `account_registrations` via the bincode-serde blob. +#[test] +fn tc014_account_registration_roundtrip() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::bip32::ExtendedPubKey; + use platform_wallet::changeset::AccountRegistrationEntry; + + // Synthesise a deterministic xpub from a fixed test seed so the + // test is reproducible without external fixtures. + let xpub = ExtendedPubKey::decode(&hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC2DF1050E2E8FF49C85C2", + ).unwrap()).unwrap(); + let entry = AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + account_xpub: xpub, + }; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xFE); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + account_registrations: vec![entry.clone()], + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p2.lock_conn_for_test(); + let blob_bytes: Vec = conn + .query_row( + "SELECT account_xpub_bytes FROM account_registrations WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + let decoded: AccountRegistrationEntry = + platform_wallet_storage::sqlite::schema::blob::decode(&blob_bytes).unwrap(); + assert_eq!(decoded.account_type, entry.account_type); + assert_eq!(decoded.account_xpub, xpub); + drop(tmp); +} + +/// TC-010: AssetLockChangeSet round-trips lifecycle data — including +/// the embedded `Transaction` and optional `AssetLockProof` — through +/// the bincode-serde payload in `asset_locks.lifecycle_blob`. +#[test] +fn tc010_asset_lock_roundtrip() { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, Txid}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + + let txid = Txid::from_byte_array([0x42; 32]); + let outpoint = OutPoint { txid, vout: 1 }; + let transaction = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let entry = AssetLockEntry { + out_point: outpoint, + transaction: transaction.clone(), + account_index: 5, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 9, + amount_duffs: 12_345, + status: AssetLockStatus::Built, + proof: None, + }; + let mut locks = AssetLockChangeSet::default(); + locks.asset_locks.insert(outpoint, entry.clone()); + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xFD); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + asset_locks: Some(locks), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let bucketed = platform_wallet_storage::sqlite::schema::asset_locks::load_state( + &p2.lock_conn_for_test(), + &w, + ) + .unwrap(); + let by_outpoint = &bucketed[&5]; + let tracked = &by_outpoint[&outpoint]; + assert_eq!(tracked.amount, entry.amount_duffs); + assert_eq!(tracked.account_index, entry.account_index); + assert_eq!(tracked.identity_index, entry.identity_index); + assert_eq!(tracked.funding_type, entry.funding_type); + assert_eq!(tracked.status, entry.status); + assert_eq!(tracked.transaction.version, transaction.version); + drop(tmp); +} + +/// TC-012: DashPay profile + payment overlay round-trip through the +/// dashpay_* tables via bincode-serde blobs. +#[test] +fn tc012_dashpay_overlay_roundtrip() { + use dpp::prelude::Identifier; + use platform_wallet::wallet::identity::{DashPayProfile, PaymentEntry}; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xFC); + ensure_wallet_meta(&persister, &w); + let identity_id = Identifier::from([0x55; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + identity_id.as_slice().try_into().unwrap(), + ) + .unwrap(); + + let profile = DashPayProfile { + display_name: Some("alice".into()), + bio: Some("hello world".into()), + avatar_url: None, + avatar_hash: None, + avatar_fingerprint: None, + public_message: Some("public".into()), + }; + let payment = PaymentEntry::new_sent(Identifier::from([0x66; 32]), 7_500, Some("lunch".into())); + + let mut profiles = std::collections::BTreeMap::new(); + profiles.insert(identity_id, Some(profile.clone())); + let mut by_tx = std::collections::BTreeMap::new(); + by_tx.insert("tx-aaaa".to_string(), payment.clone()); + let mut payments = std::collections::BTreeMap::new(); + payments.insert(identity_id, by_tx); + + persister + .store( + w, + PlatformWalletChangeSet { + dashpay_profiles: Some(profiles), + dashpay_payments_overlay: Some(payments), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p2.lock_conn_for_test(); + // V002: dashpay_profiles is keyed by identity_id only. + let profile_blob: Vec = conn + .query_row( + "SELECT profile_blob FROM dashpay_profiles WHERE identity_id = ?1", + rusqlite::params![identity_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + let decoded_profile: DashPayProfile = + platform_wallet_storage::sqlite::schema::blob::decode(&profile_blob).unwrap(); + assert_eq!(decoded_profile, profile); + + let payment_blob: Vec = conn + .query_row( + "SELECT overlay_blob FROM dashpay_payments_overlay WHERE identity_id = ?1 AND payment_id = ?2", + rusqlite::params![identity_id.as_slice(), "tx-aaaa"], + |row| row.get(0), + ) + .unwrap(); + let decoded_payment: PaymentEntry = + platform_wallet_storage::sqlite::schema::blob::decode(&payment_blob).unwrap(); + assert_eq!(decoded_payment, payment); + drop(tmp); +} + +/// TC-082 (lint): grep for `Box` in the crate's sources. +#[test] +fn tc082_no_box_dyn_error_in_src() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut offenders = Vec::new(); + visit(&root, &mut offenders); + assert!( + offenders.is_empty(), + "Box found in: {offenders:?}" + ); + + fn visit(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let p = entry.path(); + if p.is_dir() { + visit(&p, out); + } else if p.extension().is_some_and(|e| e == "rs") { + let s = std::fs::read_to_string(&p).unwrap(); + if s.contains("Box rusqlite::Connection { + let conn = rusqlite::Connection::open(path).expect("rw open"); + conn.pragma_update(None, "busy_timeout", 5_000i64).unwrap(); + conn +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_restore_staged_validation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_restore_staged_validation.rs new file mode 100644 index 00000000000..593285e2efc --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_restore_staged_validation.rs @@ -0,0 +1,221 @@ +#![allow(clippy::field_reassign_with_default)] + +//! CMT-002 — schema-history-presence and max-version gates must bind +//! to the STAGED copy, not the first source handle. +//! +//! These regression tests pin that a forward-version or +//! schema-history-missing source is rejected AND the live destination +//! is left untouched, closing the validate-then-reopen TOCTOU window. + +mod common; + +use std::fs; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::{SqlitePersister, WalletStorageError}; + +fn seed_one_row(persister: &SqlitePersister, w: &[u8; 32]) { + ensure_wallet_meta(persister, w); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(5), + last_processed_height: Some(5), + ..Default::default() + }); + persister.store(*w, cs).unwrap(); +} + +const SENTINEL: &[u8] = b"do-not-replace-me"; + +/// A source whose `refinery_schema_history` MAX(version) exceeds the +/// embedded max is rejected and the destination is left untouched. +#[test] +fn forward_version_rejected_destination_unchanged() { + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xF0)); + let backup_path = persister.backup_to(tmp.path()).unwrap(); + drop(persister); + + // Bump the staged source past the embedded max so the staged-copy + // gate must reject it. + let bumped = 1_000_000i64; + { + let conn = rusqlite::Connection::open(&backup_path).unwrap(); + conn.execute( + "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ + VALUES (?1, 'future', '', '0')", + rusqlite::params![bumped], + ) + .unwrap(); + } + + let dest = tmp.path().join("dest.db"); + fs::write(&dest, SENTINEL).unwrap(); + let err = SqlitePersister::restore_from_skip_backup(&dest, &backup_path); + assert!( + matches!( + err, + Err(WalletStorageError::SchemaVersionUnsupported { .. }) + ), + "expected SchemaVersionUnsupported, got {err:?}" + ); + assert_eq!( + fs::read(&dest).unwrap(), + SENTINEL, + "destination must be untouched when the staged copy is rejected" + ); +} + +/// A source lacking `refinery_schema_history` but otherwise +/// integrity-valid is rejected and the destination is left untouched. +#[test] +fn missing_schema_history_rejected_destination_unchanged() { + let tmp = tempfile::tempdir().unwrap(); + let fake_src = tmp.path().join("empty.db"); + rusqlite::Connection::open(&fake_src).unwrap(); + + let dest = tmp.path().join("dest.db"); + fs::write(&dest, SENTINEL).unwrap(); + let err = SqlitePersister::restore_from_skip_backup(&dest, &fake_src); + assert!( + matches!(err, Err(WalletStorageError::SchemaHistoryMissing)), + "expected SchemaHistoryMissing, got {err:?}" + ); + assert_eq!( + fs::read(&dest).unwrap(), + SENTINEL, + "destination must be untouched when the staged copy is rejected" + ); +} + +/// CMT-001: if the staged copy fails its forward-version gate, the +/// destination's `-wal` / `-shm` siblings must NOT be +/// unlinked. Deleting them before validation succeeds = un-checkpointed +/// committed pages lost on rollback. +#[test] +fn rejected_restore_leaves_wal_shm_siblings_intact() { + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xF1)); + let backup_path = persister.backup_to(tmp.path()).unwrap(); + drop(persister); + + // Bump the source past the embedded max. + let bumped = 1_000_000i64; + { + let conn = rusqlite::Connection::open(&backup_path).unwrap(); + conn.execute( + "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ + VALUES (?1, 'future', '', '0')", + rusqlite::params![bumped], + ) + .unwrap(); + } + + let dest = tmp.path().join("dest.db"); + fs::write(&dest, SENTINEL).unwrap(); + let wal = tmp.path().join("dest.db-wal"); + let shm = tmp.path().join("dest.db-shm"); + fs::write(&wal, b"wal-sentinel").unwrap(); + fs::write(&shm, b"shm-sentinel").unwrap(); + + let err = SqlitePersister::restore_from_skip_backup(&dest, &backup_path); + assert!( + matches!( + err, + Err(WalletStorageError::SchemaVersionUnsupported { .. }) + ), + "expected SchemaVersionUnsupported, got {err:?}" + ); + assert_eq!(fs::read(&dest).unwrap(), SENTINEL, "main DB preserved"); + assert!( + wal.exists(), + "WAL sibling must NOT be unlinked on rejection" + ); + assert!( + shm.exists(), + "SHM sibling must NOT be unlinked on rejection" + ); + assert_eq!(fs::read(&wal).unwrap(), b"wal-sentinel"); + assert_eq!(fs::read(&shm).unwrap(), b"shm-sentinel"); +} + +/// CMT-010: a forward-version source must fail BEFORE the full file +/// is streamed into the destination's parent dir. We assert no +/// NamedTempFile from the staging copy survives in the parent dir +/// after the rejection — the cheap source-side sniff fails fast. +#[test] +fn forward_version_rejected_before_staging() { + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xF3)); + let backup_path = persister.backup_to(tmp.path()).unwrap(); + drop(persister); + + // Bump the source past the embedded max. + { + let conn = rusqlite::Connection::open(&backup_path).unwrap(); + conn.execute( + "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ + VALUES (?1, 'future', '', '0')", + rusqlite::params![1_000_000i64], + ) + .unwrap(); + } + + let dest_dir = tempfile::tempdir().unwrap(); + let dest = dest_dir.path().join("dest.db"); + fs::write(&dest, SENTINEL).unwrap(); + + let before: usize = fs::read_dir(dest_dir.path()).unwrap().count(); + let err = SqlitePersister::restore_from_skip_backup(&dest, &backup_path); + let after: usize = fs::read_dir(dest_dir.path()).unwrap().count(); + + assert!( + matches!( + err, + Err(WalletStorageError::SchemaVersionUnsupported { .. }) + ), + "expected SchemaVersionUnsupported, got {err:?}" + ); + assert_eq!( + after, before, + "no staging temp file should be left behind on pre-staging rejection" + ); +} + +/// Happy path: a valid in-range backup still restores and the +/// destination reflects the restored bytes. +#[test] +fn valid_backup_roundtrips() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xF2); + seed_one_row(&persister, &w); + let backup_path = persister.backup_to(tmp.path()).unwrap(); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(999), + last_processed_height: Some(999), + ..Default::default() + }); + persister.store(w, cs).unwrap(); + drop(persister); + + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore_from"); + + let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&path); + let p2 = SqlitePersister::open(cfg).unwrap(); + let conn = p2.lock_conn_for_test(); + let h: i64 = conn + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + h, 5, + "restored bytes must reflect the backup, not the mutation" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs b/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs new file mode 100644 index 00000000000..3315c951418 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs @@ -0,0 +1,169 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-CODE-003 / TC-CODE-026 — `PlatformWalletPersistence::delete_wallet` +//! and `::commit_writes` are reachable through the trait (not just the +//! inherent methods on `SqlitePersister`). Dispatch happens through +//! `Arc` so consumers don't need a +//! concrete backend type at the call site. +//! +//! - TC-CODE-003-default — trait default `delete_wallet` returns an +//! empty report (proven via a NoPlatformPersistence-style stub). +//! - TC-CODE-003-sqlite — trait-dispatched `delete_wallet` on +//! `SqlitePersister` actually cascades the on-disk rows. +//! - TC-CODE-026-1 — trait default `commit_writes` returns an empty +//! report (same stub backend). +//! - TC-CODE-026-2 — trait-dispatched `commit_writes` on +//! `SqlitePersister` matches the inherent behavior (success). + +mod common; + +use std::sync::Arc; + +use common::{ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, ro_conn, wid}; +use platform_wallet::changeset::{ + ClientStartState, CommitReport, CoreChangeSet, DeleteWalletReport, PersistenceError, + PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::FlushMode; + +fn core_with_height(synced_height: u32, last_processed_height: u32) -> CoreChangeSet { + CoreChangeSet { + synced_height: Some(synced_height), + last_processed_height: Some(last_processed_height), + ..Default::default() + } +} + +fn changeset(core: CoreChangeSet) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(core), + ..Default::default() + } +} + +/// Stub persister that exercises every trait default — `delete_wallet` +/// and `commit_writes` are inherited from the trait, so an empty impl +/// suffices. +struct DefaultsOnlyPersister; + +impl PlatformWalletPersistence for DefaultsOnlyPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } +} + +/// TC-CODE-003-default — `delete_wallet` default impl returns an +/// empty report keyed by the requested wallet id. Backends with no +/// per-wallet disk state inherit this; consumers use the same Ok-arm +/// regardless of backend. +#[test] +fn tc_code_003_default_delete_wallet_returns_empty_report() { + let persister: Arc = Arc::new(DefaultsOnlyPersister); + let wallet_id = wid(0xAB); + let report: DeleteWalletReport = persister + .delete_wallet(wallet_id) + .expect("default delete_wallet must be infallible"); + assert_eq!(report.wallet_id, wallet_id); + assert!(report.backup_path.is_none()); + assert!(report.rows_removed_per_table.is_empty()); +} + +/// TC-CODE-003-sqlite — trait-dispatched `delete_wallet` on +/// `SqlitePersister` cascades the on-disk rows. Without the trait +/// impl this call would resolve to the default and silently leave +/// the rows in place. +#[test] +fn tc_code_003_sqlite_trait_delete_wallet_cascades_rows() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x55); + ensure_wallet_meta(&persister, &w); + // Land a per-wallet row via the trait so we have something to + // cascade. + PlatformWalletPersistence::store(&persister, w, changeset(core_with_height(11, 11))) + .expect("store must succeed in Immediate mode"); + + let count_for = |id: &[u8; 32]| -> i64 { + ro_conn(&path) + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(count_for(&w), 1); + + // Dispatch through the trait — this is the call shape + // `PlatformWalletManager` uses. + let report = PlatformWalletPersistence::delete_wallet(&persister, w) + .expect("trait delete_wallet must succeed"); + assert_eq!(report.wallet_id, w); + assert!( + report.backup_path.is_some(), + "trait-dispatched delete_wallet must take an auto-backup (safe-by-default)" + ); + + assert_eq!(count_for(&w), 0); +} + +/// TC-CODE-026-1 — `commit_writes` default impl returns an empty +/// `CommitReport`. Drives backwards-compat for stubs + +/// `NoPlatformPersistence`-style implementors that don't track dirty +/// state. +#[test] +fn tc_code_026_1_commit_writes_default_returns_empty_report() { + let persister: Arc = Arc::new(DefaultsOnlyPersister); + let report: CommitReport = persister + .commit_writes() + .expect("default commit_writes must be infallible"); + assert!(report.is_ok()); + assert!(report.succeeded.is_empty()); + assert!(report.failed.is_empty()); + assert!(report.still_pending.is_empty()); +} + +/// TC-CODE-026-2 — trait-dispatched `commit_writes` on +/// `SqlitePersister` flushes every dirty wallet just like the +/// inherent method (no behavioral drift across dispatch). +#[test] +fn tc_code_026_2_sqlite_trait_commit_writes_flushes_dirty() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let a = wid(0x11); + let b = wid(0x22); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + PlatformWalletPersistence::store(&persister, a, changeset(core_with_height(3, 3))) + .expect("store A"); + PlatformWalletPersistence::store(&persister, b, changeset(core_with_height(4, 4))) + .expect("store B"); + + let report = PlatformWalletPersistence::commit_writes(&persister) + .expect("trait commit_writes must succeed"); + assert!(report.is_ok(), "report={report:?}"); + assert_eq!(report.succeeded.len(), 2); + + let count_for = |id: &[u8; 32]| -> i64 { + ro_conn(&path) + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(count_for(&a), 1); + assert_eq!(count_for(&b), 1); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs new file mode 100644 index 00000000000..439d8030a47 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs @@ -0,0 +1,468 @@ +//! Integration tests for the V002 migration — cascade-only identity +//! references (CODE-002). Six TCs covering: migration on populated +//! V001 data, fresh V002 FK chain, orphan identities, real wallet_id +//! token-balance writes, sentinel-row refusal, and a post-migration +//! `PRAGMA foreign_key_check` clean-room verification. + +#[path = "common/mod.rs"] +mod common; + +use common::fresh_persister; + +use rusqlite::{params, Connection}; + +/// Apply only V001 to a fresh in-memory connection (V002 is held back +/// so populated-DB migration TCs can stage realistic rows first). +/// +/// Uses refinery's `Runner::set_target` to apply migrations up to and +/// including V001 only, which leaves V002 unapplied for the test body +/// to trigger explicitly. +fn apply_only_v001(conn: &mut Connection) { + use refinery::Target; + platform_wallet_storage::sqlite::migrations::runner() + .set_target(Target::Version(1)) + .run(conn) + .expect("apply V001 only"); + conn.pragma_update(None, "foreign_keys", "ON") + .expect("enable FKs"); +} + +/// Apply V002 over an already-V001 connection, matching the +/// persister's open-path discipline: FKs OFF for the duration of the +/// migration tx so DROP-TABLE cascades don't wipe rows mid-migration, +/// then back ON. +fn apply_v002_with_fk_toggle(conn: &mut Connection) -> Result { + conn.pragma_update(None, "foreign_keys", "OFF") + .expect("disable FKs"); + let result = platform_wallet_storage::sqlite::migrations::run(conn); + conn.pragma_update(None, "foreign_keys", "ON") + .expect("re-enable FKs"); + result +} + +/// Apply the full embedded migration set (V001 + V002), routing +/// through the open-path FK toggle so the V002 cascade rewrite +/// doesn't wipe the rows it is trying to preserve. +fn apply_all(conn: &mut Connection) { + conn.pragma_update(None, "foreign_keys", "OFF") + .expect("disable FKs"); + platform_wallet_storage::sqlite::migrations::run(conn).expect("apply migrations"); + conn.pragma_update(None, "foreign_keys", "ON") + .expect("enable FKs"); +} + +/// TC-CODE-002-1 — V001→V002 migration on populated DB preserves rows. +/// +/// Stage one wallet + identity + identity_keys + dashpay_profile + +/// token_balance under V001's schema. Apply V002. Every row must still +/// be readable through the new columns / PK shape; the column lists +/// must reflect the V002 shape. +#[test] +fn tc_code_002_1_v001_to_v002_preserves_rows() { + let mut conn = Connection::open_in_memory().expect("open in-memory"); + apply_only_v001(&mut conn); + + let wid = [11u8; 32]; + let iid = [22u8; 32]; + let kid: i64 = 3; + let tid = [33u8; 32]; + + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wid[..]], + ) + .expect("insert wallet_metadata"); + + conn.execute( + "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + VALUES (?1, 7, ?2, X'AA', 0)", + params![&wid[..], &iid[..]], + ) + .expect("insert identity"); + + conn.execute( + "INSERT INTO identity_keys (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, ?3, X'BB', X'CC', NULL)", + params![&wid[..], &iid[..], kid], + ) + .expect("insert identity_keys"); + + conn.execute( + "INSERT INTO dashpay_profiles (wallet_id, identity_id, profile_blob) VALUES (?1, ?2, X'DD')", + params![&wid[..], &iid[..]], + ) + .expect("insert dashpay_profile"); + + conn.execute( + "INSERT INTO token_balances (wallet_id, identity_id, token_id, balance, updated_at) \ + VALUES (?1, ?2, ?3, 42, 100)", + params![&wid[..], &iid[..], &tid[..]], + ) + .expect("insert token_balance"); + + // Apply V002 on top through the FK-toggle helper that mirrors + // the persister open-path discipline. + apply_v002_with_fk_toggle(&mut conn).expect("V002 migration on populated DB"); + + // identities row preserved. + let identity_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identities WHERE identity_id = ?1", + params![&iid[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(identity_count, 1, "identity row survived V002"); + + // identity_keys row preserved (PK now identity_id, key_id). + let key_blob: Vec = conn + .query_row( + "SELECT public_key_blob FROM identity_keys WHERE identity_id = ?1 AND key_id = ?2", + params![&iid[..], kid], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(key_blob, vec![0xBB], "identity_keys blob preserved"); + + // dashpay_profiles row preserved. + let profile_blob: Vec = conn + .query_row( + "SELECT profile_blob FROM dashpay_profiles WHERE identity_id = ?1", + params![&iid[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(profile_blob, vec![0xDD], "dashpay_profile blob preserved"); + + // token_balances row preserved with balance/updated_at intact. + let (balance, updated_at): (i64, i64) = conn + .query_row( + "SELECT balance, updated_at FROM token_balances WHERE identity_id = ?1 AND token_id = ?2", + params![&iid[..], &tid[..]], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .unwrap(); + assert_eq!(balance, 42); + assert_eq!(updated_at, 100); + + // PK shape: token_balances must no longer carry wallet_id. + let mut stmt = conn + .prepare("SELECT name FROM pragma_table_info('token_balances')") + .unwrap(); + let cols: Vec = stmt + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert!( + !cols.iter().any(|c| c == "wallet_id"), + "token_balances must not carry wallet_id after V002; got {cols:?}" + ); +} + +/// TC-CODE-002-2 — fresh V002 enforces the cascade chain +/// wallet_metadata → identities → identity-owned tables. +#[test] +fn tc_code_002_2_cascade_chain_wallet_to_identity_to_tokens() { + let mut conn = Connection::open_in_memory().expect("open in-memory"); + apply_all(&mut conn); + + let wid = [11u8; 32]; + let iid = [22u8; 32]; + let tid = [33u8; 32]; + + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, NULL, X'AA', 0)", + params![&iid[..], &wid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO token_balances (identity_id, token_id, balance, updated_at) \ + VALUES (?1, ?2, 1, 0)", + params![&iid[..], &tid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO dashpay_profiles (identity_id, profile_blob) VALUES (?1, X'DD')", + params![&iid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO identity_keys (identity_id, key_id, public_key_blob, public_key_hash) \ + VALUES (?1, 0, X'BB', X'CC')", + params![&iid[..]], + ) + .unwrap(); + + // Deleting the wallet cascades through identities to every + // identity-owned table. + conn.execute( + "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + params![&wid[..]], + ) + .unwrap(); + + for (table, where_clause) in [ + ("identities", "identity_id = ?1"), + ("token_balances", "identity_id = ?1"), + ("dashpay_profiles", "identity_id = ?1"), + ("identity_keys", "identity_id = ?1"), + ] { + let n: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE {where_clause}"), + params![&iid[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 0, "cascade did not reach {table}"); + } +} + +/// TC-CODE-002-3 — orphan identity (NULL wallet_id) writes + reads OK. +#[test] +fn tc_code_002_3_orphan_identity_nullable_wallet_id() { + let mut conn = Connection::open_in_memory().expect("open in-memory"); + apply_all(&mut conn); + + let iid = [77u8; 32]; + // No wallet_metadata row at all — identity row carries NULL. + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, NULL, NULL, X'AA', 0)", + params![&iid[..]], + ) + .expect("orphan insert must succeed"); + + let (wid_opt, blob): (Option>, Vec) = conn + .query_row( + "SELECT wallet_id, entry_blob FROM identities WHERE identity_id = ?1", + params![&iid[..]], + |row| Ok((row.get::<_, Option>>(0)?, row.get::<_, Vec>(1)?)), + ) + .unwrap(); + assert!(wid_opt.is_none(), "orphan identity wallet_id must be NULL"); + assert_eq!(blob, vec![0xAA]); + + // Schema reports wallet_id as nullable (`notnull` = 0). + let notnull: i64 = conn + .query_row( + "SELECT \"notnull\" FROM pragma_table_info('identities') WHERE name = 'wallet_id'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(notnull, 0, "identities.wallet_id must be nullable"); +} + +/// TC-CODE-002-4 — token-balance write under a real wallet_id passes FK. +/// +/// Confirms the V002 schema accepts the identity-id-keyed write path +/// the post-fix consumer takes — no `SQLITE_CONSTRAINT_FOREIGNKEY`. +#[test] +fn tc_code_002_4_token_balance_with_real_identity_succeeds() { + let mut conn = Connection::open_in_memory().expect("open in-memory"); + apply_all(&mut conn); + + let wid = [11u8; 32]; + let iid = [22u8; 32]; + let tid = [33u8; 32]; + + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, NULL, X'AA', 0)", + params![&iid[..], &wid[..]], + ) + .unwrap(); + + // The fix-side schema: token_balance write needs no wallet_id. + conn.execute( + "INSERT INTO token_balances (identity_id, token_id, balance, updated_at) \ + VALUES (?1, ?2, 12345, 0)", + params![&iid[..], &tid[..]], + ) + .expect("token_balance write must succeed under V002"); +} + +/// TC-CODE-002-5 — migration refuses if legacy sentinel rows are present. +/// +/// Stage a V001 DB with one `token_balances` row keyed under +/// `WalletId::default()` (`X'00..00'`). Run migrations — the V002 +/// guard must abort the transaction with the typed +/// `MigrationRequiresManualCleanup` signal. +#[test] +fn tc_code_002_5_migration_refuses_legacy_sentinel_rows() { + use rusqlite::ErrorCode; + + let mut conn = Connection::open_in_memory().expect("open in-memory"); + apply_only_v001(&mut conn); + + let sentinel = [0u8; 32]; + let iid = [22u8; 32]; + let tid = [33u8; 32]; + + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&sentinel[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + VALUES (?1, NULL, ?2, X'AA', 0)", + params![&sentinel[..], &iid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO token_balances (wallet_id, identity_id, token_id, balance, updated_at) \ + VALUES (?1, ?2, ?3, 0, 0)", + params![&sentinel[..], &iid[..], &tid[..]], + ) + .unwrap(); + + let err = platform_wallet_storage::sqlite::migrations::run(&mut conn) + .expect_err("migration must refuse legacy sentinel rows"); + let msg = format!("{err:?}"); + // The guard rides a CHECK constraint on a temp table column named + // `sentinel_count`; SQLite's error text quotes the failing CHECK + // predicate so the column name surfaces verbatim. + assert!( + msg.contains("sentinel_count"), + "expected sentinel_count CHECK in error text, got: {msg}" + ); + // Walk the source chain to confirm the underlying SQLite error is + // a real `ConstraintViolation` (the CHECK failure), not an + // unrelated I/O surprise. + let mut source: Option<&dyn std::error::Error> = Some(&err); + let mut found_constraint = false; + while let Some(s) = source { + if let Some(rusqlite::Error::SqliteFailure(e, _)) = s.downcast_ref::() { + if matches!(e.code, ErrorCode::ConstraintViolation) { + found_constraint = true; + break; + } + } + source = s.source(); + } + assert!( + found_constraint, + "expected ConstraintViolation in error chain" + ); + + // Going through the persister's open-path re-classifies that raw + // CHECK failure into the typed + // `MigrationRequiresManualCleanup` error, which is what operators + // actually see. Drive the same scenario via `SqlitePersister::open` + // on a file-backed DB to verify the re-classification. + use std::sync::Once; + static INIT: Once = Once::new(); + INIT.call_once(|| {}); + + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("wallet.db"); + { + let mut conn = Connection::open(&path).expect("open file db"); + apply_only_v001(&mut conn); + let sentinel = [0u8; 32]; + let iid2 = [99u8; 32]; + let tid2 = [88u8; 32]; + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&sentinel[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + VALUES (?1, NULL, ?2, X'AA', 0)", + params![&sentinel[..], &iid2[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO token_balances (wallet_id, identity_id, token_id, balance, updated_at) \ + VALUES (?1, ?2, ?3, 0, 0)", + params![&sentinel[..], &iid2[..], &tid2[..]], + ) + .unwrap(); + } + let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&path); + let open_err = match platform_wallet_storage::SqlitePersister::open(cfg) { + Ok(_) => panic!("open must refuse the sentinel-laden DB"), + Err(e) => e, + }; + assert!( + matches!( + open_err, + platform_wallet_storage::WalletStorageError::MigrationRequiresManualCleanup { + table: "token_balances", + count + } if count >= 1 + ), + "expected MigrationRequiresManualCleanup, got: {open_err:?}" + ); +} + +/// TC-CODE-002-6 — `PRAGMA foreign_key_check` post-migration is empty. +/// +/// Sanity-check the migrated schema has no dangling FK references — +/// neither among the migrated rows nor among the new tables. +#[test] +fn tc_code_002_6_pragma_foreign_key_check_clean() { + let mut conn = Connection::open_in_memory().expect("open in-memory"); + apply_only_v001(&mut conn); + + let wid = [11u8; 32]; + let iid = [22u8; 32]; + let tid = [33u8; 32]; + + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + VALUES (?1, NULL, ?2, X'AA', 0)", + params![&wid[..], &iid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO token_balances (wallet_id, identity_id, token_id, balance, updated_at) \ + VALUES (?1, ?2, ?3, 1, 0)", + params![&wid[..], &iid[..], &tid[..]], + ) + .unwrap(); + + apply_v002_with_fk_toggle(&mut conn).expect("apply V002"); + + let mut stmt = conn.prepare("PRAGMA foreign_key_check").unwrap(); + let rows: Vec<(String, i64, String, i64)> = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert!( + rows.is_empty(), + "foreign_key_check found dangling refs: {rows:?}" + ); + + // End-to-end smoke: a fresh persister opens the migrated DB + // cleanly (no extra FK errors at open time). + let (_p, _tmp, _path) = fresh_persister(); +} diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index d812c764b9b..8634debf261 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -70,8 +70,16 @@ default = ["bls", "eddsa"] bls = ["key-wallet/bls", "key-wallet-manager/bls"] eddsa = ["key-wallet/eddsa", "key-wallet-manager/eddsa"] shielded = ["dep:grovedb-commitment-tree", "dep:zip32", "dash-sdk/shielded", "dpp/shielded-client"] -# Opt-in serde derives on the changeset types. Activates `key-wallet/serde`, -# `key-wallet-manager/serde`, and `dash-sdk/serde`. `dpp` derives serde unconditionally. +# Opt-in serde derives on the changeset types in `src/changeset/` plus +# the per-identity / DashPay scalar types those changesets carry. +# Activates `key-wallet/serde` (which transitively activates +# `dashcore/serde` and `dash-network/serde`) so every leaf type in a +# changeset payload — `TransactionRecord`, `Utxo`, `InstantLock`, +# `AccountType`, `AddressInfo`, `ExtendedPubKey`, `Network` — has a +# working `serde::Serialize`/`Deserialize` impl. `dpp` derives serde +# unconditionally; `key-wallet-manager/serde` covers `DerivedAddress` +# and friends; `dash-sdk/serde` surfaces serde on SDK-owned types +# reachable from changeset payloads. serde = [ "dep:serde", "key-wallet/serde", diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 00a9e39706b..d530b45d326 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -136,6 +136,14 @@ pub struct CoreChangeSet { /// upstream `project_derived_addresses` uses, so two records in /// the same flush both pushing the same gap-limit boundary /// collapse to one entry. + /// + /// `#[serde(skip)]`: persisters that need the breadcrumb write + /// it to a dedicated typed table (see + /// `platform_wallet_storage::sqlite::schema::core_state`) rather + /// than serialising the parent changeset wholesale, so excluding + /// it from the serde round-trip has no functional cost even now + /// that `key-wallet-manager/serde` would make it serializable. + #[cfg_attr(feature = "serde", serde(skip))] pub addresses_derived: Vec, /// Highest chainlock the wallet has applied (mirrors @@ -645,6 +653,10 @@ pub struct PlatformAddressBalanceEntry { pub account_index: u32, pub address_index: u32, pub address: PlatformP2PKHAddress, + #[cfg_attr( + feature = "serde", + serde(with = "crate::changeset::serde_adapters::address_funds") + )] pub funds: AddressFunds, } @@ -732,6 +744,10 @@ pub struct AssetLockEntry { /// BIP44 account index that funded this asset lock (UTXO source). pub account_index: u32, /// Which funding account to derive the one-time key from. + #[cfg_attr( + feature = "serde", + serde(with = "crate::changeset::serde_adapters::asset_lock_funding_type") + )] pub funding_type: AssetLockFundingType, /// Identity index used during creation. pub identity_index: u32, diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 1f669091c58..aa0a329bf07 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -16,6 +16,8 @@ pub mod core_bridge; pub mod identity_manager_start_state; pub mod merge; pub mod platform_address_sync_start_state; +#[cfg(feature = "serde")] +pub mod serde_adapters; #[cfg(feature = "shielded")] pub mod shielded_changeset; #[cfg(feature = "shielded")] @@ -39,4 +41,7 @@ pub use platform_address_sync_start_state::PlatformAddressSyncStartState; pub use shielded_changeset::ShieldedChangeSet; #[cfg(feature = "shielded")] pub use shielded_sync_start_state::{ShieldedSubwalletStartState, ShieldedSyncStartState}; -pub use traits::{PersistenceError, PlatformWalletPersistence}; +pub use traits::{ + CommitReport, DeleteWalletReport, PersistenceError, PersistenceErrorKind, + PlatformWalletPersistence, +}; diff --git a/packages/rs-platform-wallet/src/changeset/serde_adapters.rs b/packages/rs-platform-wallet/src/changeset/serde_adapters.rs new file mode 100644 index 00000000000..330fab55c80 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/serde_adapters.rs @@ -0,0 +1,88 @@ +//! `serde::with` adapters for upstream types that don't (yet) derive +//! their own `Serialize`/`Deserialize`. +//! +//! Compiled only when the crate's `serde` feature is on (see the +//! `#[cfg(feature = "serde")]` gate on the `pub mod` line in +//! `changeset/mod.rs`). + +use dash_sdk::platform::address_sync::AddressFunds; +use dpp::balances::credits::Credits; +use dpp::prelude::AddressNonce; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Adapter for `AssetLockFundingType` (upstream has no serde derive). +/// +/// Encodes each variant as a stable u8 tag — same tag space the +/// hand-rolled `BlobWriter` used before the serde swap, kept for +/// forward/backward compatibility of on-disk blobs. +pub mod asset_lock_funding_type { + use super::*; + + pub fn serialize( + value: &AssetLockFundingType, + serializer: S, + ) -> Result { + let tag: u8 = match value { + AssetLockFundingType::IdentityRegistration => 0, + AssetLockFundingType::IdentityTopUp => 1, + AssetLockFundingType::IdentityTopUpNotBound => 2, + AssetLockFundingType::IdentityInvitation => 3, + AssetLockFundingType::AssetLockAddressTopUp => 4, + AssetLockFundingType::AssetLockShieldedAddressTopUp => 5, + }; + tag.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + let tag = u8::deserialize(deserializer)?; + Ok(match tag { + 0 => AssetLockFundingType::IdentityRegistration, + 1 => AssetLockFundingType::IdentityTopUp, + 2 => AssetLockFundingType::IdentityTopUpNotBound, + 3 => AssetLockFundingType::IdentityInvitation, + 4 => AssetLockFundingType::AssetLockAddressTopUp, + 5 => AssetLockFundingType::AssetLockShieldedAddressTopUp, + other => { + return Err(serde::de::Error::custom(format!( + "unknown AssetLockFundingType tag: {other}" + ))) + } + }) + } +} + +/// Adapter for `AddressFunds` (re-exported from `dash-sdk`; no serde +/// derive there). Encodes the two scalar fields side-by-side. +pub mod address_funds { + use super::*; + + #[derive(Serialize, Deserialize)] + struct Wire { + nonce: AddressNonce, + balance: Credits, + } + + pub fn serialize( + value: &AddressFunds, + serializer: S, + ) -> Result { + Wire { + nonce: value.nonce, + balance: value.balance, + } + .serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + let w = Wire::deserialize(deserializer)?; + Ok(AddressFunds { + nonce: w.nonce, + balance: w.balance, + }) + } +} diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 1e567e451ed..326730ca7cf 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -3,19 +3,53 @@ //! Implementors choose their own storage engine (SQLite, file, memory, remote). //! The traits guarantee that deltas are persisted atomically. +use std::collections::BTreeMap; +use std::error::Error as StdError; +use std::fmt; +use std::path::PathBuf; + use crate::changeset::changeset::PlatformWalletChangeSet; use crate::changeset::client_start_state::ClientStartState; use crate::wallet::platform_wallet::WalletId; use dashcore::Txid; use key_wallet::managed_account::transaction_record::TransactionRecord; +/// Retry classification for [`PersistenceError::Backend`]. +/// +/// The kind carries the persistor's `is_transient()` contract across +/// the trait boundary so consumers can decide whether to retry, undo +/// in-memory state, or surface the failure to the user without +/// guessing from a string message. +/// +/// The enum is intentionally NOT `#[non_exhaustive]`: adding a new +/// kind MUST force every consumer match to update explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PersistenceErrorKind { + /// The persistor reports the write was not committed and the + /// buffered state is preserved (e.g. `SQLITE_BUSY`, `SQLITE_FULL`, + /// `SQLITE_IOERR`, `SQLITE_NOMEM`). Callers MAY retry with + /// exponential backoff. + Transient, + /// The persistor reports an unrecoverable failure (schema + /// corruption, logic bug, I/O error not covered by the transient + /// class). Callers MUST NOT retry — the buffered changeset is + /// gone and the same call will keep failing. + Fatal, + /// SQL constraint / foreign-key / integrity violation. Distinct + /// from `Fatal` so callers can distinguish "your data is wrong" + /// (caller bug) from "the storage engine is unhappy" (operator / + /// infrastructure problem). Treated as fatal for retry purposes. + Constraint, +} + /// Errors returned by a [`PlatformWalletPersistence`] backend. /// /// Concrete (non-`Box`) so callers and downstream /// traits can compose the result types without erasing the /// error's shape. Backends that don't fit cleanly into -/// [`Self::LockPoisoned`] render their native error via -/// [`Self::backend`] into [`Self::Backend`]. +/// [`Self::LockPoisoned`] route their native error through +/// [`Self::backend_with_kind`] (or [`Self::backend`] when the kind +/// isn't known) into [`Self::Backend`]. #[derive(Debug, thiserror::Error)] pub enum PersistenceError { /// An internal synchronization primitive is poisoned (a @@ -26,38 +60,113 @@ pub enum PersistenceError { LockPoisoned, /// Error bubbled up from the underlying storage engine - /// (SQLite, file I/O, FFI callback, etc.). Carries the - /// backend's error message; the original error type is - /// intentionally erased so the trait stays object-safe - /// without generic error parameters. - #[error("persistence backend error: {0}")] - Backend(String), + /// (SQLite, file I/O, FFI callback, etc.). + /// + /// `kind` carries the retry classification — see + /// [`PersistenceErrorKind`]. `source` is a boxed typed error so + /// callers that need finer detail can downcast (the canonical + /// SQLite backend boxes `WalletStorageError`, which preserves the + /// full typed source chain). + #[error("persistence backend error ({kind:?}): {source}")] + Backend { + kind: PersistenceErrorKind, + source: Box, + }, } impl PersistenceError { - /// Convenience constructor that stringifies any - /// `Display` error into [`PersistenceError::Backend`]. - pub fn backend(err: impl std::fmt::Display) -> Self { - Self::Backend(err.to_string()) + /// Construct a [`Self::Backend`] from any boxable error, + /// classified as [`PersistenceErrorKind::Fatal`]. + /// + /// Use this when the caller does not (or cannot) classify the + /// kind. Defaulting to `Fatal` is the conservative choice: a + /// misclassification reads as "do not retry" rather than + /// spuriously retrying a permanent failure. + pub fn backend(source: E) -> Self + where + E: Into>, + { + Self::Backend { + kind: PersistenceErrorKind::Fatal, + source: source.into(), + } + } + + /// Construct a [`Self::Backend`] with an explicit kind. Use this + /// at the persistor boundary where the kind is known (e.g. + /// `From` checks `is_transient()` and the + /// constraint codes before calling this). + pub fn backend_with_kind(kind: PersistenceErrorKind, source: E) -> Self + where + E: Into>, + { + Self::Backend { + kind, + source: source.into(), + } + } + + /// `true` if the error is a `Backend` whose kind is + /// [`PersistenceErrorKind::Transient`]. `LockPoisoned`, `Fatal`, + /// and `Constraint` all read as non-transient. + pub fn is_transient(&self) -> bool { + matches!( + self, + Self::Backend { + kind: PersistenceErrorKind::Transient, + .. + } + ) + } + + /// Retry-policy classification for the error. + /// + /// Returns `None` for [`Self::LockPoisoned`] (which is its own + /// trait-level variant) and `Some(kind)` for [`Self::Backend`]. + /// Callers that always need a kind should treat `None` as + /// [`PersistenceErrorKind::Fatal`]. + pub fn kind(&self) -> Option { + match self { + Self::LockPoisoned => None, + Self::Backend { kind, .. } => Some(*kind), + } } } -// Ergonomic conversions so backends can `.into()` a message without -// spelling out the enum variant. The common pattern in FFI-style -// backends is `Err(format!("...").into())`; the `From` impl -// keeps that terse while routing into the typed error. +/// String-shaped messages from legacy callers (predominantly the FFI +/// persister) flow through here. The original construction site +/// usually doesn't know whether the failure is transient or fatal, so +/// the conservative default is [`PersistenceErrorKind::Fatal`] — +/// callers that DO know the kind use [`PersistenceError::backend_with_kind`] +/// directly. impl From for PersistenceError { fn from(msg: String) -> Self { - Self::Backend(msg) + Self::backend(StringSource(msg)) } } impl From<&str> for PersistenceError { fn from(msg: &str) -> Self { - Self::Backend(msg.to_string()) + Self::backend(StringSource(msg.to_string())) } } +/// Minimal error wrapper around an owned message so the +/// `From` / `From<&str>` impls can hand a typed source into +/// `Backend.source` without allocating a `dyn Error` for every +/// legacy call site. Kept private to the module — call sites stay +/// terse via `.into()`. +#[derive(Debug)] +struct StringSource(String); + +impl fmt::Display for StringSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl StdError for StringSource {} + /// Storage backend for [`PlatformWalletChangeSet`] deltas. /// /// The persister persists what the changeset carries — nothing more, @@ -115,6 +224,20 @@ impl From<&str> for PersistenceError { /// sequence as potentially performing I/O at either point. If a caller needs /// to guarantee a batch flush, it should call `flush` explicitly after all /// `store` calls and treat `store` as a best-effort buffer hint. +/// +/// # Wallet ID convention +/// +/// Methods that take a `wallet_id: WalletId` parameter accept +/// `WalletId::default()` (all-zero bytes) as a sentinel meaning **"this +/// object does not belong to any wallet"** — i.e. an orphan / observed-only +/// entity. This is the trait-level contract; the V002 SQLite schema permits +/// null `wallet_id` on identity-owned tables, and storage backends MUST +/// round-trip a default [`WalletId`] losslessly. +/// +/// Higher layers MAY enforce stricter rules — e.g. the FFI entry point +/// `platform_wallet_manager_identity_sync_register_identity` rejects a +/// default [`WalletId`] to prevent UX accidents — but the persistence +/// trait itself does NOT reject orphans. pub trait PlatformWalletPersistence: Send + Sync { /// Buffer a changeset for later persistence. /// @@ -124,6 +247,10 @@ pub trait PlatformWalletPersistence: Send + Sync { /// Returns an error if the internal accumulator cannot be accessed /// (e.g. mutex poisoning). Callers that use fire-and-forget /// semantics should log the error rather than propagating. + /// + /// Pass `WalletId::default()` to mark the changeset as orphan-owned + /// (no parent wallet) — see the **Wallet ID convention** section on + /// the trait. fn store( &self, wallet_id: WalletId, @@ -132,6 +259,38 @@ pub trait PlatformWalletPersistence: Send + Sync { /// Write all buffered changesets atomically for the given wallet, then /// clear that wallet's buffer. + /// + /// # Errors + /// + /// Implementations classify failures via + /// [`PersistenceErrorKind`] on the returned + /// [`PersistenceError::Backend`] so callers can drive retry policy + /// off [`PersistenceError::is_transient`]: + /// + /// - **[`PersistenceErrorKind::Transient`]** — for the canonical + /// SQLite backend that's `SQLITE_BUSY` / `SQLITE_LOCKED`, and as + /// of ATOM-008 also the I/O-class codes `SQLITE_FULL` / + /// `SQLITE_IOERR` / `SQLITE_NOMEM`: the buffered changeset is + /// preserved (re-merged via the buffer's `restore` path so any + /// `store` that landed during the failed flush wins on LWW + /// fields), and the caller MAY retry with exponential backoff. + /// - **[`PersistenceErrorKind::Constraint`]** — SQL + /// constraint / FK / integrity violation. Caller bug; the data + /// is rejected by the schema. MUST NOT retry without changing + /// the data. + /// - **[`PersistenceErrorKind::Fatal`]** — everything else + /// (schema corruption, logic bugs, I/O outside the transient + /// class): the buffer is dropped, the staged changeset is gone, + /// and the backend logs a structured `tracing::error!`. The + /// caller MUST NOT retry — the data is not recoverable through + /// this trait. + /// + /// [`PersistenceError::LockPoisoned`] is fatal but distinguished + /// at the variant level so callers can pattern-match on it. + /// + /// Pass `WalletId::default()` for `wallet_id` to flush the orphan + /// changeset buffer — see the **Wallet ID convention** section on + /// the trait. fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError>; /// Load the full client state from storage. @@ -187,6 +346,10 @@ pub trait PlatformWalletPersistence: Send + Sync { /// advantage of this contract by emitting a synthetic record with a /// placeholder transaction body, since reconstructing the full /// `Transaction` over the C ABI is not free and isn't needed. + /// + /// Pass `WalletId::default()` for `wallet_id` to look up an + /// orphan-owned record — see the **Wallet ID convention** section + /// on the trait. fn get_core_tx_record( &self, _wallet_id: WalletId, @@ -194,4 +357,118 @@ pub trait PlatformWalletPersistence: Send + Sync { ) -> Result, PersistenceError> { Ok(None) } + + /// Cascade-delete every persisted row owned by `wallet_id`. + /// + /// The default impl is a no-op that returns an empty + /// [`DeleteWalletReport`]. Backends with no per-wallet state + /// on disk (e.g. [`NoPlatformPersistence`](crate::wallet::persister::NoPlatformPersistence)) + /// inherit it. + /// + /// # Errors + /// + /// - [`PersistenceErrorKind::Transient`] (e.g. `SQLITE_BUSY`): + /// callers MAY retry with backoff. + /// - [`PersistenceErrorKind::Constraint`] / [`PersistenceErrorKind::Fatal`] + /// / [`PersistenceError::LockPoisoned`]: callers MUST NOT retry; + /// the disk state may carry orphan rows that an admin tool has + /// to clean up out-of-band. + /// + /// Pass `WalletId::default()` for `wallet_id` to cascade-delete + /// the orphan-owned bucket (rows with null `wallet_id` in the V002 + /// schema) — see the **Wallet ID convention** section on the trait. + fn delete_wallet(&self, wallet_id: WalletId) -> Result { + Ok(DeleteWalletReport { + wallet_id, + backup_path: None, + rows_removed_per_table: BTreeMap::new(), + }) + } + + /// Flush every dirty wallet's buffered changeset to durable storage. + /// + /// The default impl is a no-op that returns an empty + /// [`CommitReport`]. Backends that flush inline (e.g. SQLite in + /// [`FlushMode::Immediate`](https://docs.rs/platform-wallet-storage)) + /// or that have nothing to flush ([`NoPlatformPersistence`](crate::wallet::persister::NoPlatformPersistence)) + /// inherit it. + /// + /// # Errors + /// + /// Returns `Err` ONLY when even enumerating the dirty set fails + /// (e.g. the buffer mutex is poisoned). Per-wallet flush failures + /// land on `report.failed` with the classified `PersistenceError` + /// per wallet so a single bad wallet does not hide its siblings' + /// success. `report.still_pending` lists wallets that were never + /// attempted because an earlier per-flush call short-circuited + /// the loop (today: `LockPoisoned`). + /// + /// Atomicity is per-wallet, not cross-wallet: there is no + /// transaction spanning multiple wallets. + /// + /// The returned [`CommitReport`] may carry `WalletId::default()` + /// entries in `succeeded` / `failed` / `still_pending` to denote + /// the orphan changeset bucket — see the **Wallet ID convention** + /// section on the trait. + fn commit_writes(&self) -> Result { + Ok(CommitReport { + succeeded: Vec::new(), + failed: Vec::new(), + still_pending: Vec::new(), + }) + } +} + +/// Outcome of a [`PlatformWalletPersistence::commit_writes`] call. +/// +/// Each dirty wallet's per-flush result lands in exactly one of the +/// three vectors so a single failed wallet doesn't hide its siblings' +/// success (or vice-versa). Callers can retry `still_pending` directly; +/// `failed` carries the classified `PersistenceError` per wallet so +/// transient-vs-fatal decisions stay local. +/// +/// A `WalletId::default()` entry in any of the three vectors denotes +/// the orphan changeset bucket — see the **Wallet ID convention** +/// section on [`PlatformWalletPersistence`]. +#[derive(Debug)] +pub struct CommitReport { + /// Wallets that flushed successfully (durable on disk). + pub succeeded: Vec, + /// Wallets whose flush returned an error. The `PersistenceError` + /// carries the classification and source per + /// [`PersistenceErrorKind`]. + pub failed: Vec<(WalletId, PersistenceError)>, + /// Wallets we never attempted because an earlier per-flush call + /// short-circuited the loop (today: a `LockPoisoned` — the + /// connection mutex is gone). + pub still_pending: Vec, +} + +impl CommitReport { + /// `true` when every dirty wallet flushed cleanly. + pub fn is_ok(&self) -> bool { + self.failed.is_empty() && self.still_pending.is_empty() + } +} + +/// Outcome of a [`PlatformWalletPersistence::delete_wallet`] call. +/// +/// Lives on the trait so consumers can match on the report without +/// pulling in a backend-specific crate. The SQLite backend builds an +/// instance with `rows_removed_per_table` populated; backends that +/// don't track per-table row counts emit an empty map. +#[derive(Debug, Clone)] +pub struct DeleteWalletReport { + /// The wallet that was deleted. `WalletId::default()` here means + /// the orphan bucket was the delete target — see the **Wallet ID + /// convention** section on [`PlatformWalletPersistence`]. + pub wallet_id: WalletId, + /// Absolute path of the pre-delete auto-backup taken before the + /// cascade. `None` when the backend skipped the backup + /// (intentionally — e.g. the SQLite CLI's `--no-auto-backup` — or + /// because the backend has no backup concept). + pub backup_path: Option, + /// Per-table row counts the backend deleted. Empty for backends + /// that don't expose per-table accounting. + pub rows_removed_per_table: BTreeMap<&'static str, usize>, } diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 71988e5aea4..8762eb8e756 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -182,6 +182,32 @@ pub enum PlatformWalletError { #[error("Shielded sub-wallet not bound: call bind_shielded first")] ShieldedNotBound, + + /// `load_from_persistor` refused to silently drop platform-address + /// state because the persister returned a non-empty + /// `platform_addresses` map but an empty `wallets` map — i.e. it + /// reports `LOAD_UNIMPLEMENTED` for `ClientStartState::wallets` + /// (e.g. PR #3692 territory). The host MUST either wait for + /// wallet rehydration to land or re-register each wallet + /// individually via `register_wallet`, which drains + /// `platform_addresses` correctly on a per-wallet basis. + #[error( + "persister reports unimplemented load areas {unimplemented:?}; \ + refusing to silently drop {orphan_addresses_count} orphan \ + platform-address slice(s) — re-register wallets individually \ + or wait for wallet rehydration" + )] + PersistorMissingWalletRehydration { + unimplemented: Vec, + orphan_addresses_count: usize, + }, + + /// `register_wallet` could not commit the wallet's registration + /// changeset to the persister (after one transient-class retry, if + /// applicable). In-memory state has been rolled back so the wallet + /// is NOT visible through the manager. + #[error("wallet registration failed for {wallet_id}: {reason}")] + WalletRegistrationFailed { wallet_id: String, reason: String }, } /// Check whether an SDK error indicates that an InstantSend lock proof was diff --git a/packages/rs-platform-wallet/src/manager/identity_sync.rs b/packages/rs-platform-wallet/src/manager/identity_sync.rs index 7023190d91f..9791997b9af 100644 --- a/packages/rs-platform-wallet/src/manager/identity_sync.rs +++ b/packages/rs-platform-wallet/src/manager/identity_sync.rs @@ -36,12 +36,16 @@ //! follow-up — see the TODO inside [`IdentitySyncManager::sync_now`] //! and the matching note on [`IdentityTokenSyncInfo::contract_id`]. //! -//! Persister wiring caveat: the manager is identity-scoped, but -//! [`PlatformWalletPersistence::store`] takes a `WalletId`. The -//! changesets written here use [`WalletId::default()`] (`[0u8; 32]`) -//! as a sentinel — token-balance persistence on the FFI / SQLite side -//! is keyed by `(identity_id, token_id)`, so the wallet id is unused -//! on that callback path. +//! Persister wiring: the manager is identity-scoped, but +//! [`PlatformWalletPersistence::store`] takes a `WalletId`. Each +//! identity registration carries the parent wallet id explicitly +//! (`Option`) so the changeset emitted by +//! `apply_fresh_balances` is dispatched under the real parent wallet +//! when one is known. Identities registered with `None` (e.g. observed +//! out-of-wallet identities) are persisted under the all-zero sentinel +//! — V002's nullable `identities.wallet_id` accepts the orphan case +//! and the cascade chain still flows `wallet_metadata → identities → +//! identity-owned tables` for every identity with a real parent. //! //! Not auto-started. Call [`IdentitySyncManager::start`] once //! identities are registered and the SDK is connected. @@ -152,12 +156,21 @@ where /// SDK handle used to issue `IdentityTokenBalancesQuery` / /// `TokenAmount::fetch_many` from the sync loop. sdk: Arc, - /// Persister for [`TokenBalanceChangeSet`] writes. Identity-scoped - /// changesets travel under [`WalletId::default()`] since this - /// manager is not wallet-scoped — see crate-level docs. Generic - /// over `P` so every `persister.store(...)` call on the hot sync - /// loop dispatches statically. + /// Persister for [`TokenBalanceChangeSet`] writes. Each store call + /// uses the per-identity parent `WalletId` recorded at + /// registration time (see `identity_parent_wallet`). Generic over + /// `P` so every `persister.store(...)` call on the hot sync loop + /// dispatches statically. persister: Arc

, + /// Per-identity parent wallet, populated at registration. Looked + /// up by `apply_fresh_balances` so the persister sees the real + /// owning wallet for cascade purposes. `None` means the identity + /// is observed without a known parent (orphan) — the changeset is + /// still dispatched under the all-zero sentinel, which V002's + /// nullable `identities.wallet_id` accepts. Kept in its own + /// `RwLock` so the read on the hot path doesn't fight the + /// per-identity state writer. + identity_parent_wallet: RwLock>>, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, /// Monotonically increasing generation counter. Incremented each @@ -196,6 +209,7 @@ where Self { sdk, persister, + identity_parent_wallet: RwLock::new(BTreeMap::new()), background_cancel: StdMutex::new(None), background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), @@ -212,10 +226,29 @@ where /// in `token_ids` becomes a watched row with `balance = 0`, /// `contract_id = Identifier::default()`, /// `identity_contract_nonce = 0`. The next sync pass populates - /// real values. + /// real values. The parent wallet is recorded as `None` (orphan); + /// callers that know the parent wallet should use + /// [`register_identity_with_wallet`](Self::register_identity_with_wallet) + /// instead so balance writes cascade through the correct wallet. pub async fn register_identity(&self, identity_id: Identifier, token_ids: I) where I: IntoIterator, + { + self.register_identity_with_wallet(identity_id, None, token_ids) + .await; + } + + /// Like [`register_identity`](Self::register_identity) but binds + /// the identity to a parent `WalletId`. The recorded id flows + /// through every `persister.store(wallet_id, …)` call this + /// manager makes for `identity_id`. + pub async fn register_identity_with_wallet( + &self, + identity_id: Identifier, + parent_wallet_id: Option, + token_ids: I, + ) where + I: IntoIterator, { let tokens: Vec = token_ids .into_iter() @@ -235,6 +268,9 @@ where tokens, }, ); + drop(state); + let mut parents = self.identity_parent_wallet.write().await; + parents.insert(identity_id, parent_wallet_id); } /// Remove the registry row for `identity_id`. @@ -243,6 +279,9 @@ where pub async fn unregister_identity(&self, identity_id: &Identifier) { let mut state = self.state.write().await; state.remove(identity_id); + drop(state); + let mut parents = self.identity_parent_wallet.write().await; + parents.remove(identity_id); } /// Replace the watched-token list for an already-registered @@ -572,15 +611,26 @@ where return; }; - // The persister API is wallet-scoped (`store(wallet_id, ..)`) - // but this manager is identity-scoped. Use the zero-byte - // sentinel — the FFI / SQLite token-balance write paths key - // their rows by `(identity_id, token_id)` and ignore the - // wallet id on this changeset. - let sentinel: WalletId = WalletId::default(); - if let Err(e) = self.persister.store(sentinel, cs.into()) { + // Dispatch the changeset under the identity's real parent + // wallet id when one is known. V002 stores `token_balances` + // keyed by `(identity_id, token_id)` and the FK chain runs + // `wallet_metadata → identities → token_balances`, so the + // wallet id only matters here to keep the persister's + // per-wallet buffer / FK accounting honest. Orphan identities + // (`None`) fall back to the all-zero sentinel — V002's + // nullable `identities.wallet_id` accepts it. + let wallet_id = { + let parents = self.identity_parent_wallet.read().await; + parents + .get(&identity_id) + .copied() + .flatten() + .unwrap_or_default() + }; + if let Err(e) = self.persister.store(wallet_id, cs.into()) { tracing::error!( identity_id = %identity_id, + wallet_id = %hex::encode(wallet_id), error = %e, "identity-sync: failed to persist token balance changeset" ); diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 8e7af9be1c7..4a0b5c3d8ab 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -3,7 +3,12 @@ use std::collections::BTreeMap; use std::sync::Arc; -use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; +#[cfg(feature = "shielded")] +use crate::changeset::ShieldedSyncStartState; +use crate::changeset::{ + ClientStartState, ClientWalletStartState, PlatformAddressSyncStartState, + PlatformWalletPersistence, +}; use crate::error::PlatformWalletError; use crate::wallet::core::WalletBalance; use crate::wallet::identity::IdentityManager; @@ -31,12 +36,10 @@ impl PlatformWalletManager

{ /// [`WalletManager`]: key_wallet_manager::WalletManager pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { let ClientStartState { - mut platform_addresses, + platform_addresses, wallets, - // Shielded restore happens lazily on `bind_shielded`, - // not here — drop the snapshot at this entry point. #[cfg(feature = "shielded")] - shielded: _, + shielded, } = self.persister.load().map_err(|e| { PlatformWalletError::WalletCreation(format!( "Failed to load persisted client state: {}", @@ -44,6 +47,39 @@ impl PlatformWalletManager

{ )) })?; + let orphan_count = platform_addresses.len(); + let wallets_empty = wallets.is_empty(); + + // Stash the platform-address + shielded slices in the cache so + // any later `register_wallet` / `bind_shielded` calls drain + // from there instead of re-issuing `persister.load()` per + // wallet (CODE-017). Done BEFORE the CODE-001 gate so even the + // refusal path leaves the cache populated — the host's + // per-wallet `register_wallet` fallback then runs at the + // already-cached zero-load cost. + *self.persisted_addresses.write().await = Some(platform_addresses); + #[cfg(feature = "shielded")] + { + *self.persisted_shielded.write().await = Some(Arc::new(shielded)); + } + + // Refuse to silently drop persisted platform-address slices + // when the persister returned `wallets={}` despite having + // populated `platform_addresses`. That shape is the contract + // signature of a persister whose `wallets` rehydration is + // unimplemented (`LOAD_UNIMPLEMENTED = &["ClientStartState::wallets"]` + // on `SqlitePersister` as of #3625; the rehydration ships in + // #3692). Without this gate the loop below executes zero + // iterations and the cached slices are never consumed. + // Host falls back to per-wallet `register_wallet` (which now + // drains the cache populated above). + if wallets_empty && orphan_count > 0 { + return Err(PlatformWalletError::PersistorMissingWalletRehydration { + unimplemented: vec!["ClientStartState::wallets".to_string()], + orphan_addresses_count: orphan_count, + }); + } + let persister_dyn: Arc = Arc::clone(&self.persister) as _; // Track every wallet successfully inserted into @@ -142,11 +178,17 @@ impl PlatformWalletManager

{ broadcaster, ); - // Initialize the platform-address provider. If the snapshot - // carried a slice for this wallet, restore it directly; - // otherwise do a fresh scan from the live wallet manager. - // Failures break to the rollback path below. - if let Some(persisted) = platform_addresses.remove(&wallet_id) { + // Initialize the platform-address provider. If the cached + // snapshot carried a slice for this wallet, restore it + // directly; otherwise do a fresh scan from the live wallet + // manager. Failures break to the rollback path below. + let persisted_slice = self + .persisted_addresses + .write() + .await + .as_mut() + .and_then(|m| m.remove(&wallet_id)); + if let Some(persisted) = persisted_slice { if let Err(e) = platform_wallet .platform() .initialize_from_persisted(persisted) @@ -191,4 +233,100 @@ impl PlatformWalletManager

{ Ok(()) } + + /// Drain this wallet's persisted platform-address slice from the + /// shared cache, populating the cache via a single + /// `persister.load()` if it hasn't been populated yet (CODE-017). + /// + /// Returns `Ok(None)` when the persister has no slice for this + /// wallet — caller should fall back to `platform().initialize()`. + /// The slice is **removed** on return so a subsequent call for + /// the same wallet drops through to the no-slice branch. + pub(super) async fn take_persisted_platform_addresses( + &self, + wallet_id: &WalletId, + ) -> Result, PlatformWalletError> { + self.ensure_persisted_state_loaded().await?; + Ok(self + .persisted_addresses + .write() + .await + .as_mut() + .and_then(|m| m.remove(wallet_id))) + } + + /// Snapshot of the persisted shielded state, populating the cache + /// via a single `persister.load()` if needed. The snapshot is + /// shared (`Arc`) so multiple + /// [`PlatformWallet::bind_shielded_with_snapshot`] calls reuse the + /// same allocation; restore is filtered per-wallet at consume time. + /// Returns `Ok(None)` when no shielded state was persisted. + /// + /// Hosts that drive `bind_shielded` themselves (the FFI layer) + /// should fetch the snapshot here once and pass it through to + /// every wallet bind so the shielded restore step skips its own + /// `persister.load()` (CODE-017). + /// + /// [`PlatformWallet::bind_shielded_with_snapshot`]: + /// crate::wallet::PlatformWallet::bind_shielded_with_snapshot + #[cfg(feature = "shielded")] + pub async fn cached_persisted_shielded( + &self, + ) -> Result>, PlatformWalletError> { + self.ensure_persisted_state_loaded().await?; + Ok(self + .persisted_shielded + .read() + .await + .as_ref() + .map(Arc::clone)) + } + + /// Drop any persisted slice for `wallet_id` from the address + /// cache. Called from `remove_wallet` so a future re-registration + /// of the same id cannot re-apply stale persisted state. The + /// shielded cache is **not** invalidated per-wallet: it's a shared + /// snapshot and a re-bind for the new wallet under a fresh + /// `WalletId` filter is a no-op (restore_for_wallet filters by + /// wallet_id). CODE-017. + pub(super) async fn invalidate_persisted_for_wallet(&self, wallet_id: &WalletId) { + if let Some(map) = self.persisted_addresses.write().await.as_mut() { + map.remove(wallet_id); + } + } + + /// Populate `persisted_addresses` (and `persisted_shielded`) from a + /// single `persister.load()` call if either cache slot is still + /// `None`. Idempotent — a second call after population is a cheap + /// read-lock check. + async fn ensure_persisted_state_loaded(&self) -> Result<(), PlatformWalletError> { + // Fast path: cache already populated. + if self.persisted_addresses.read().await.is_some() { + return Ok(()); + } + // Slow path: take write locks and double-check before issuing + // the load — a concurrent caller may have populated between + // the read above and the writes here. + let mut addr_guard = self.persisted_addresses.write().await; + if addr_guard.is_some() { + return Ok(()); + } + let ClientStartState { + platform_addresses, + wallets: _, + #[cfg(feature = "shielded")] + shielded, + } = self.persister.load().map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to load persisted client state: {}", + e + )) + })?; + *addr_guard = Some(platform_addresses); + #[cfg(feature = "shielded")] + { + *self.persisted_shielded.write().await = Some(Arc::new(shielded)); + } + Ok(()) + } } diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 78fc7db3c55..3420107a55a 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -16,7 +16,11 @@ use tokio_util::sync::CancellationToken; use key_wallet_manager::WalletManager; -use crate::changeset::{spawn_wallet_event_adapter, PlatformWalletPersistence}; +#[cfg(feature = "shielded")] +use crate::changeset::ShieldedSyncStartState; +use crate::changeset::{ + spawn_wallet_event_adapter, PlatformAddressSyncStartState, PlatformWalletPersistence, +}; use crate::events::{PlatformEventHandler, PlatformEventManager}; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; @@ -72,6 +76,30 @@ pub struct PlatformWalletManager { pub(super) shielded_coordinator: Arc>>>, pub(super) persister: Arc

, + /// Per-wallet `PlatformAddressSyncStartState` slices, lazily + /// populated by the first call into `ensure_persisted_state_loaded` + /// (made by `load_from_persistor`, `register_wallet`, or + /// `bind_shielded`). `None` means "not yet loaded"; `Some(map)` + /// means `persister.load()` has been called exactly once and the + /// per-wallet slices are available for consumption. Entries are + /// `remove`d as wallets register so the map drains naturally; new + /// wallets registered after exhaustion fall through to a + /// `platform().initialize()` rescan. Invalidated on `remove_wallet` + /// to keep a stale persisted slice from re-applying if the same + /// `WalletId` re-registers later. See CODE-017. + pub(super) persisted_addresses: tokio::sync::RwLock< + Option>, + >, + /// Cached shielded snapshot from the same `persister.load()` call + /// that populates [`persisted_addresses`]. `bind_shielded` reads it + /// to restore per-subwallet notes + watermarks without re-loading. + /// The snapshot is read-only (filtered per-wallet at consume time + /// via `restore_for_wallet`); restore is idempotent so multiple + /// binds reuse the same snapshot. CODE-017. + /// + /// [`persisted_addresses`]: Self::persisted_addresses + #[cfg(feature = "shielded")] + pub(super) persisted_shielded: tokio::sync::RwLock>>, /// Cancellation token + join handle for the wallet-event adapter /// task. Held so [`shutdown`] can stop it cleanly when the manager /// is torn down. @@ -152,6 +180,9 @@ impl PlatformWalletManager

{ #[cfg(feature = "shielded")] shielded_coordinator, persister, + persisted_addresses: tokio::sync::RwLock::new(None), + #[cfg(feature = "shielded")] + persisted_shielded: tokio::sync::RwLock::new(None), event_adapter_cancel, event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), } diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index ca8d5051b39..931115a49b4 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -278,18 +278,43 @@ impl PlatformWalletManager

{ } } - if let Err(e) = self.persister.store(wallet_id, registration_changeset) { + // Drive the typed `PersistenceError` kind off the wire so a + // transient (e.g. `SQLITE_BUSY`) gets one backoff retry while a + // fatal / constraint failure undoes the in-memory insert and + // surfaces `WalletRegistrationFailed`. Without this, a failed + // store leaves the wallet visible in `wallet_manager` without + // a `wallet_metadata` row, so every subsequent per-wallet write + // FK-violates against an absent parent. + let store_outcome = self + .persister + .store(wallet_id, registration_changeset.clone()); + let store_err = match store_outcome { + Ok(()) => None, + Err(e) if e.is_transient() => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "transient persist failure on wallet registration; retrying once" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + self.persister + .store(wallet_id, registration_changeset) + .err() + } + Err(e) => Some(e), + }; + if let Some(e) = store_err { tracing::error!( wallet_id = %hex::encode(wallet_id), error = %e, - "failed to persist wallet registration changeset" + "failed to persist wallet registration changeset; undoing in-memory insert" ); let mut wm = self.wallet_manager.write().await; let _ = wm.remove_wallet(&wallet_id); - return Err(PlatformWalletError::WalletCreation(format!( - "Failed to persist wallet registration changeset: {}", - e - ))); + return Err(PlatformWalletError::WalletRegistrationFailed { + wallet_id: hex::encode(wallet_id), + reason: e.to_string(), + }); } // Build the PlatformWallet handle. @@ -308,24 +333,20 @@ impl PlatformWalletManager

{ broadcaster, ); - // Load persisted state. The only area wired up today is the - // platform-address provider — `from_persisted` skips the live - // `AddressPool` scan `initialize` would otherwise do. - // Per-wallet UTXOs / unused asset locks ship in the snapshot - // but don't have an active restore path yet. + // Drain this wallet's persisted platform-address slice from + // the manager's shared cache (CODE-017) — populated lazily by + // the first call here, by `load_from_persistor`, or by + // `bind_shielded`. Eliminates the N+1 `persister.load()` / + // mutex-contention pattern that used to fire one full read + // per wallet at register time. // // The two `?` returns below would otherwise leave the wallet // half-registered (present in `wallet_manager` from the // earlier `insert_wallet`, absent from `self.wallets`), // poisoning every retry on `WalletAlreadyExists`. Roll back // before bailing — same shape as `manager::load`. - let crate::changeset::ClientStartState { - mut platform_addresses, - wallets: _, - #[cfg(feature = "shielded")] - shielded: _, - } = match platform_wallet.load_persisted() { - Ok(state) => state, + let persisted_slice = match self.take_persisted_platform_addresses(&wallet_id).await { + Ok(slice) => slice, Err(e) => { let mut wm = self.wallet_manager.write().await; let _ = wm.remove_wallet(&wallet_id); @@ -336,7 +357,7 @@ impl PlatformWalletManager

{ } }; - if let Some(persisted) = platform_addresses.remove(&wallet_id) { + if let Some(persisted) = persisted_slice { if let Err(e) = platform_wallet .platform() .initialize_from_persisted(persisted) @@ -415,6 +436,11 @@ impl PlatformWalletManager

{ .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))? }; + // Drop any cached persisted slice for this wallet so a future + // re-registration under the same id cannot apply stale state + // (CODE-017 cache-invalidation contract). + self.invalidate_persisted_for_wallet(wallet_id).await; + // Detach the wallet's shielded state from the network // coordinator. After the Phase-2b refactor the coordinator // owns the per-`SubwalletId` viewing-key registry and the @@ -436,6 +462,42 @@ impl PlatformWalletManager

{ .await; } + // Persist the deletion. In-memory cleanup above is complete by + // this point — the wallet is gone from `wallet_manager`, + // `self.wallets`, the shielded coordinator, and the identity + // sync manager. The persister call cascade-deletes the on-disk + // rows so the next `load()` doesn't resurrect a half-gone + // wallet. Backends with no disk concept inherit the trait + // default (noop) — `SqlitePersister` overrides. + // + // Error policy mirrors `register_wallet` (CODE-018): a + // transient failure gets one retry with brief backoff; any + // remaining failure logs structured context and we return Ok — + // the user wanted this wallet gone and the in-memory side is + // already cleaned up. Orphan rows that survive a fatal failure + // are cleanable out-of-band via an admin tool. + let delete_outcome = self.persister.delete_wallet(*wallet_id); + let delete_err = match delete_outcome { + Ok(_) => None, + Err(e) if e.is_transient() => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "transient persist failure on remove_wallet; retrying once" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + self.persister.delete_wallet(*wallet_id).err() + } + Err(e) => Some(e), + }; + if let Some(e) = delete_err { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "remove_wallet: persister.delete_wallet failed; in-memory cleanup complete, disk state may have orphan rows" + ); + } + Ok(removed) } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs index d47d8cdcef6..ae9ab5b6b93 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs @@ -648,9 +648,7 @@ mod tests { _wallet_id: WalletId, _txid: &Txid, ) -> Result, PersistenceError> { - Err(PersistenceError::Backend( - "simulated backend failure".into(), - )) + Err(PersistenceError::backend("simulated backend failure")) } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 14db85ec8bb..34262ab022f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -340,6 +340,26 @@ impl PlatformWallet { seed: &[u8], accounts: &[u32], coordinator: &Arc, + ) -> Result<(), PlatformWalletError> { + self.bind_shielded_with_snapshot(seed, accounts, coordinator, None) + .await + } + + /// Same as [`bind_shielded`](Self::bind_shielded) but the caller + /// supplies a pre-loaded shielded start-state snapshot, so the + /// restore step skips its own `persister.load()` call. The + /// [`PlatformWalletManager`](crate::manager::PlatformWalletManager) + /// uses this with its shared `cached_persisted_shielded` snapshot + /// to drop the N+1 load that fires when several wallets bind at + /// startup (CODE-017). Pass `None` to fall back to a per-call + /// `persister.load()`. + #[cfg(feature = "shielded")] + pub async fn bind_shielded_with_snapshot( + &self, + seed: &[u8], + accounts: &[u32], + coordinator: &Arc, + cached_snapshot: Option>, ) -> Result<(), PlatformWalletError> { // Phase 4d.3: derive the per-account `OrchardKeySet` map // directly — no more `ShieldedWallet` wrapper. The shared @@ -398,30 +418,33 @@ impl PlatformWallet { // Rehydrate per-subwallet notes / sync watermarks from // the persister's start state if any are present for - // this wallet. The lookup is cheap: load() is the - // boot-time snapshot, indexed by SubwalletId. Errors are - // logged but not fatal — first-launch wallets simply - // see no persisted state. - match self.persister.load() { - Ok(start) => { - if let Err(e) = coordinator + // this wallet. When the caller supplies `cached_snapshot` + // we reuse it — `PlatformWalletManager` shares one snapshot + // across every wallet's bind to avoid the N+1 `persister.load()` + // at startup (CODE-017). Otherwise fall back to a one-shot + // load: the snapshot is indexed by `SubwalletId`, so the lookup + // is cheap, and errors are logged but not fatal — first-launch + // wallets simply see no persisted state. + let restore_result = if let Some(snapshot) = cached_snapshot { + coordinator + .restore_for_wallet(self.wallet_id, snapshot.as_ref()) + .await + .map_err(|e| format!("{e}")) + } else { + match self.persister.load() { + Ok(start) => coordinator .restore_for_wallet(self.wallet_id, &start.shielded) .await - { - tracing::warn!( - wallet_id = %hex::encode(self.wallet_id), - error = %e, - "Failed to restore shielded snapshot at bind time" - ); - } - } - Err(e) => { - tracing::warn!( - wallet_id = %hex::encode(self.wallet_id), - error = %e, - "persister.load() failed at shielded bind time" - ); + .map_err(|e| format!("{e}")), + Err(e) => Err(format!("persister.load() failed: {e}")), } + }; + if let Err(reason) = restore_result { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %reason, + "Failed to restore shielded snapshot at bind time" + ); } Ok(()) } diff --git a/packages/rs-platform-wallet/tests/load_from_persistor.rs b/packages/rs-platform-wallet/tests/load_from_persistor.rs new file mode 100644 index 00000000000..c61c4d04785 --- /dev/null +++ b/packages/rs-platform-wallet/tests/load_from_persistor.rs @@ -0,0 +1,138 @@ +//! TC-CODE-001 — `load_from_persistor` must refuse to silently drop +//! platform-address state when the persister reports its `wallets` +//! rehydration is unimplemented. +//! +//! Persister contract (pre-#3692): `load()` returns +//! `wallets={}, platform_addresses={...}` because +//! `LOAD_UNIMPLEMENTED = &["ClientStartState::wallets"]`. The consumer +//! used to loop over the empty `wallets` map and drop the +//! `platform_addresses` slices at function scope. The fix forces the +//! caller to take the per-wallet `register_wallet` re-fetch path. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use platform_wallet::changeset::{ + ClientStartState, PersistenceError, PlatformAddressSyncStartState, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet::error::PlatformWalletError; +use platform_wallet::events::{EventHandler, PlatformEventHandler}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::PlatformWalletManager; + +/// Persister whose `load()` payload is configurable per test — lets +/// `load_from_persistor` see the exact `(wallets, platform_addresses)` +/// shape we want. +struct CannedLoadPersister { + payload: Mutex>, +} + +impl CannedLoadPersister { + fn new(payload: ClientStartState) -> Self { + Self { + payload: Mutex::new(Some(payload)), + } + } +} + +impl PlatformWalletPersistence for CannedLoadPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + // Hand out the canned payload exactly once — `load_from_persistor` + // only calls `load()` once per invocation. + Ok(self.payload.lock().unwrap().take().unwrap_or_default()) + } +} + +struct NoopEventHandler; +impl EventHandler for NoopEventHandler {} +impl PlatformEventHandler for NoopEventHandler {} + +fn mock_sdk() -> Arc { + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .build() + .expect("mock sdk should build"), + ) +} + +fn build_manager( + persister: Arc, +) -> Arc> { + let sdk = mock_sdk(); + let handler: Arc = Arc::new(NoopEventHandler); + Arc::new(PlatformWalletManager::new(sdk, persister, handler)) +} + +/// TC-CODE-001-a — Persister returned `wallets={}` but +/// `platform_addresses={W1, W2}` → manager must return +/// `PersistorMissingWalletRehydration` rather than silently dropping +/// the slices. +#[tokio::test] +async fn tc_code_001_a_refuses_silent_drop_of_orphan_platform_addresses() { + let w1: WalletId = [1u8; 32]; + let w2: WalletId = [2u8; 32]; + + let mut platform_addresses: BTreeMap = BTreeMap::new(); + platform_addresses.insert(w1, PlatformAddressSyncStartState::default()); + platform_addresses.insert(w2, PlatformAddressSyncStartState::default()); + + let payload = ClientStartState { + platform_addresses, + wallets: BTreeMap::new(), + #[cfg(feature = "shielded")] + shielded: Default::default(), + }; + + let persister = Arc::new(CannedLoadPersister::new(payload)); + let manager = build_manager(Arc::clone(&persister)); + + let err = manager + .load_from_persistor() + .await + .expect_err("load_from_persistor must reject orphan platform_addresses"); + + match err { + PlatformWalletError::PersistorMissingWalletRehydration { + unimplemented, + orphan_addresses_count, + } => { + assert_eq!( + orphan_addresses_count, 2, + "should report both orphan slices" + ); + assert!( + unimplemented.iter().any(|s| s.contains("wallets")), + "unimplemented list should mention wallets, got {:?}", + unimplemented + ); + } + other => panic!("expected PersistorMissingWalletRehydration, got {other:?}"), + } +} + +/// TC-CODE-001-a (negative variant) — Empty persister payload (the +/// `NoPlatformPersistence` shape) must still succeed; the gate only +/// trips when `platform_addresses` is the orphan party. +#[tokio::test] +async fn tc_code_001_a_empty_payload_succeeds() { + let persister = Arc::new(CannedLoadPersister::new(ClientStartState::default())); + let manager = build_manager(Arc::clone(&persister)); + + manager + .load_from_persistor() + .await + .expect("empty payload must succeed — same shape as NoPlatformPersistence"); +} diff --git a/packages/rs-platform-wallet/tests/persistence_error_taxonomy.rs b/packages/rs-platform-wallet/tests/persistence_error_taxonomy.rs new file mode 100644 index 00000000000..f11793e7768 --- /dev/null +++ b/packages/rs-platform-wallet/tests/persistence_error_taxonomy.rs @@ -0,0 +1,142 @@ +//! Trait-level taxonomy of `PersistenceError` (CODE-004). +//! +//! TC-CODE-004-a — `Backend { kind, source }` shape exists and the kind +//! enum exhaustively partitions retry policy. +//! TC-CODE-004-c — `source` is `Display + Send + Sync` and surfaces the +//! underlying error message. +//! +//! Storage-side mapping (TC-CODE-004-b) and the wildcard-free invariant +//! (TC-CODE-004-e) live in `platform-wallet-storage`'s test suite, where +//! the concrete `WalletStorageError` variants are in scope. + +use std::error::Error; +use std::fmt; +use std::io; + +use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; + +/// Concrete typed source used to verify the boxed-source path on the +/// trait surface. The test asserts the Display chain reaches this +/// error's message after a round-trip through `PersistenceError`. +#[derive(Debug)] +struct DummyBackend(&'static str); + +impl fmt::Display for DummyBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } +} + +impl Error for DummyBackend {} + +/// TC-CODE-004-a — every kind variant participates in the retry +/// classification without a `_ =>` wildcard. If a new kind is added +/// later, this match (and `is_transient`) must be updated explicitly. +#[test] +fn tc_code_004_a_kind_partitions_retry_policy_exhaustively() { + fn classify(kind: PersistenceErrorKind) -> bool { + // Wildcard-free: a future variant breaks the compile here on + // purpose. Do NOT collapse this into `matches!(kind, …)` with + // a wildcard — that would defeat the exhaustiveness check. + match kind { + PersistenceErrorKind::Transient => true, + PersistenceErrorKind::Fatal => false, + PersistenceErrorKind::Constraint => false, + } + } + + for (kind, expected_transient) in [ + (PersistenceErrorKind::Transient, true), + (PersistenceErrorKind::Fatal, false), + (PersistenceErrorKind::Constraint, false), + ] { + assert_eq!(classify(kind), expected_transient, "classify({kind:?})"); + let err = PersistenceError::backend_with_kind(kind, DummyBackend("x")); + assert_eq!( + err.is_transient(), + expected_transient, + "is_transient mismatch for {kind:?}" + ); + } + + // LockPoisoned is its own variant — never transient. + assert!(!PersistenceError::LockPoisoned.is_transient()); +} + +/// TC-CODE-004-a (cont.) — pattern-matching `Backend` exposes both +/// `kind` and `source` and the kind round-trips losslessly. +#[test] +fn tc_code_004_a_backend_exposes_kind_and_source() { + let err = + PersistenceError::backend_with_kind(PersistenceErrorKind::Constraint, DummyBackend("fk")); + match err { + PersistenceError::Backend { kind, source } => { + assert_eq!(kind, PersistenceErrorKind::Constraint); + assert_eq!(source.to_string(), "fk"); + } + other => panic!("expected Backend {{ .. }}, got {other:?}"), + } +} + +/// TC-CODE-004-c — the boxed source is `Send + Sync`, implements +/// `Display`, and the rendered message contains the original text. +#[test] +fn tc_code_004_c_source_is_send_sync_and_renders_underlying_message() { + // Compile-time bound: a generic `assert_send_sync` only compiles if + // the supplied type is `Send + Sync`. The source field is + // `Box` so this is structural. + fn assert_send_sync(_: &T) {} + + let io_err = io::Error::other("disk gone"); + let err = PersistenceError::backend(io_err); + match &err { + PersistenceError::Backend { source, .. } => { + assert_send_sync(source); + assert!( + source.to_string().contains("disk gone"), + "expected source message to contain 'disk gone', got: {source}" + ); + } + other => panic!("expected Backend {{ .. }}, got {other:?}"), + } + + // The outer Display chain also surfaces the source. + let rendered = err.to_string(); + assert!( + rendered.contains("disk gone"), + "expected outer Display to include source, got: {rendered}" + ); +} + +/// TC-CODE-004-e (trait-side half) — backward-compat: `From` +/// and `From<&str>` still produce a valid `Backend` and default to +/// `Fatal` kind so legacy FFI callers don't silently get classified +/// as retryable. +#[test] +fn tc_code_004_e_string_from_impls_default_to_fatal() { + let from_owned: PersistenceError = String::from("legacy ffi message").into(); + let from_borrowed: PersistenceError = "legacy ffi message".into(); + + for err in [from_owned, from_borrowed] { + match err { + PersistenceError::Backend { kind, source } => { + assert_eq!(kind, PersistenceErrorKind::Fatal); + assert_eq!(source.to_string(), "legacy ffi message"); + } + other => panic!("expected Backend {{ .. }}, got {other:?}"), + } + } +} + +/// The `backend(..)` helper exists for callers that don't know the +/// kind — it must default to `Fatal` so a misclassification reads as +/// "do not retry" rather than spuriously retrying. +#[test] +fn backend_helper_defaults_to_fatal() { + let err = PersistenceError::backend(DummyBackend("boom")); + assert!(!err.is_transient(), "default helper must not be transient"); + match err { + PersistenceError::Backend { kind, .. } => assert_eq!(kind, PersistenceErrorKind::Fatal), + other => panic!("expected Backend {{ .. }}, got {other:?}"), + } +} diff --git a/packages/rs-platform-wallet/tests/persister_load_cache.rs b/packages/rs-platform-wallet/tests/persister_load_cache.rs new file mode 100644 index 00000000000..c69f2e6ff9a --- /dev/null +++ b/packages/rs-platform-wallet/tests/persister_load_cache.rs @@ -0,0 +1,261 @@ +//! TC-CODE-017 — `PlatformWalletManager` must call `persister.load()` +//! at most once across boot + the full per-wallet +//! `register_wallet` / `bind_shielded` round, draining cached +//! `ClientStartState` slices instead of re-issuing per-wallet loads. +//! +//! Without the cache, `register_wallet` (and historically +//! `bind_shielded`) called `persister.load()` once per wallet — each +//! call held the connection mutex for a full read, so M wallets = +//! M * O(state-size) mutex-bound work at boot. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::Network; +use platform_wallet::changeset::{ + ClientStartState, PersistenceError, PlatformAddressSyncStartState, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet::events::{EventHandler, PlatformEventHandler}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::PlatformWalletManager; + +/// Persister that counts `load()` invocations and hands back a fresh +/// `ClientStartState` cloned from a stashed template each call. +/// `store` / `flush` succeed silently so post-registration writes +/// from the event-adapter don't poison the test. +struct CountingLoadPersister { + load_calls: AtomicUsize, + template_addresses: std::sync::Mutex>, +} + +impl CountingLoadPersister { + fn new(template_addresses: BTreeMap) -> Self { + Self { + load_calls: AtomicUsize::new(0), + template_addresses: std::sync::Mutex::new(template_addresses), + } + } + + fn load_call_count(&self) -> usize { + self.load_calls.load(Ordering::SeqCst) + } + + /// Replace the persister's address template — used by the + /// cache-invalidation test to assert a `remove_wallet` + + /// re-register sees the NEW state, not the stale cached one. + fn replace_template(&self, addresses: BTreeMap) { + *self.template_addresses.lock().unwrap() = addresses; + } +} + +impl PlatformWalletPersistence for CountingLoadPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + self.load_calls.fetch_add(1, Ordering::SeqCst); + // Hand out a snapshot of the template (BTreeMap of default + // state is cheap to rebuild). + let template = self.template_addresses.lock().unwrap(); + let platform_addresses: BTreeMap = template + .keys() + .map(|k| (*k, PlatformAddressSyncStartState::default())) + .collect(); + Ok(ClientStartState { + platform_addresses, + wallets: BTreeMap::new(), + #[cfg(feature = "shielded")] + shielded: Default::default(), + }) + } +} + +struct NoopEventHandler; +impl EventHandler for NoopEventHandler {} +impl PlatformEventHandler for NoopEventHandler {} + +fn mock_sdk() -> Arc { + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .build() + .expect("mock sdk should build"), + ) +} + +fn build_manager( + persister: Arc, +) -> Arc> { + let sdk = mock_sdk(); + let handler: Arc = Arc::new(NoopEventHandler); + Arc::new(PlatformWalletManager::new(sdk, persister, handler)) +} + +/// Distinct 64-byte seed per wallet, deterministic per `index`. +fn seed_bytes_for(index: u8) -> [u8; 64] { + let mut seed = [0u8; 64]; + for (i, b) in seed.iter_mut().enumerate() { + // Index influences every byte so the recomputed WalletId is + // distinct across registrations. + *b = ((i as u8).wrapping_mul(7)) + .wrapping_add(3) + .wrapping_add(index.wrapping_mul(31)); + } + seed +} + +/// TC-CODE-017-a — `register_wallet` after `load_from_persistor` must +/// reuse the cached `ClientStartState`. `persister.load()` is invoked +/// exactly once for the full M-wallet register round. +#[tokio::test] +async fn tc_code_017_a_register_after_load_reuses_cache() { + // Empty address template so `load_from_persistor` succeeds (CODE-001 + // gate only trips when wallets={} AND platform_addresses!={}). + let persister = Arc::new(CountingLoadPersister::new(BTreeMap::new())); + let manager = build_manager(Arc::clone(&persister)); + + // Single boot-time load. + manager + .load_from_persistor() + .await + .expect("empty payload boot should succeed"); + assert_eq!( + persister.load_call_count(), + 1, + "load_from_persistor must issue exactly one persister.load()" + ); + + // Register M wallets — each `register_wallet` historically called + // `persister.load()` per-wallet. With the cache it must drain the + // already-populated map and skip the load entirely. + const M: u8 = 5; + for i in 0..M { + manager + .create_wallet_from_seed_bytes( + Network::Testnet, + seed_bytes_for(i), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet registration should succeed with empty persisted state"); + } + + assert_eq!( + persister.load_call_count(), + 1, + "register_wallet after load_from_persistor must NOT trigger \ + additional persister.load() calls (saw {})", + persister.load_call_count(), + ); +} + +/// TC-CODE-017-b — Fresh boot with no prior `load_from_persistor`: +/// the very first `register_wallet` lazily populates the cache via a +/// single `persister.load()`; subsequent registrations drain the +/// cache instead of re-loading. +#[tokio::test] +async fn tc_code_017_b_lazy_cache_init_on_first_register() { + let persister = Arc::new(CountingLoadPersister::new(BTreeMap::new())); + let manager = build_manager(Arc::clone(&persister)); + + // No boot load — go straight to per-wallet registration. + const M: u8 = 4; + for i in 0..M { + manager + .create_wallet_from_seed_bytes( + Network::Testnet, + seed_bytes_for(i), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet registration should succeed"); + } + + assert_eq!( + persister.load_call_count(), + 1, + "first register_wallet must lazily issue exactly one persister.load(); \ + subsequent registrations must drain the cache (saw {})", + persister.load_call_count(), + ); +} + +/// TC-CODE-017-c — Cache invalidation: after `remove_wallet`, the +/// cached slice for that wallet_id is dropped. A subsequent +/// `register_wallet` for the SAME id with the SAME persister payload +/// must see the live (re-loaded? — no, the cache for OTHER wallets is +/// preserved) state — i.e. it cannot re-apply a stale removed-then- +/// re-cached slice and must NOT trigger an additional load. +#[tokio::test] +async fn tc_code_017_c_remove_wallet_invalidates_cache_entry() { + let persister = Arc::new(CountingLoadPersister::new(BTreeMap::new())); + let manager = build_manager(Arc::clone(&persister)); + + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + seed_bytes_for(0), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("first registration should succeed"); + let wallet_id = wallet.wallet_id(); + + let loads_after_first_register = persister.load_call_count(); + assert_eq!( + loads_after_first_register, 1, + "first registration lazily populates cache once" + ); + + // Replace the persister template so any rogue re-load after + // remove would surface a slice with bogus content. We don't read + // its content directly, but the call-count assertion + cache + // invalidation contract guarantees no stale slice survives. + let mut new_template = BTreeMap::new(); + new_template.insert(wallet_id, PlatformAddressSyncStartState::default()); + persister.replace_template(new_template); + + manager + .remove_wallet(&wallet_id) + .await + .expect("remove_wallet should succeed"); + + // Re-register the same wallet under the same id. The cache's + // entry for `wallet_id` was invalidated, so the only state in + // play for the new registration is "no slice" → fresh + // `platform().initialize()`. Crucially, no additional + // `persister.load()` fires — the cache slot stays populated + // (just minus this wallet), so the lookup is in-memory. + manager + .create_wallet_from_seed_bytes( + Network::Testnet, + seed_bytes_for(0), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("re-registration after remove should succeed"); + + assert_eq!( + persister.load_call_count(), + 1, + "remove_wallet + re-register must not trigger a second \ + persister.load() — the cache is preserved across removes \ + (only the removed wallet's slice is dropped). Saw {} call(s).", + persister.load_call_count(), + ); +} diff --git a/packages/rs-platform-wallet/tests/register_wallet_failure.rs b/packages/rs-platform-wallet/tests/register_wallet_failure.rs new file mode 100644 index 00000000000..0dc352cf643 --- /dev/null +++ b/packages/rs-platform-wallet/tests/register_wallet_failure.rs @@ -0,0 +1,217 @@ +//! TC-CODE-018 — `register_wallet` (via `create_wallet_from_seed_bytes`) +//! must drive the typed `PersistenceError` kind off the registration +//! store: transient → one backoff retry; fatal → undo in-memory state +//! and surface `WalletRegistrationFailed`. +//! +//! Without this fix, a failed register-time store leaves the wallet +//! visible in `wallet_manager` without a `wallet_metadata` row, so +//! every subsequent per-wallet write FK-violates against an absent +//! parent (CODE-002 territory). + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::Network; +use platform_wallet::changeset::{ + ClientStartState, PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet::error::PlatformWalletError; +use platform_wallet::events::{EventHandler, PlatformEventHandler}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::PlatformWalletManager; + +/// Persister scripted with a per-call queue of outcomes for `store`. +/// Drives the transient-retry and fatal-undo paths deterministically. +struct ScriptedPersister { + /// FIFO of outcomes consumed by successive `store` calls. + store_outcomes: Mutex>, + store_calls: AtomicUsize, +} + +enum StoreOutcome { + Ok, + Transient(&'static str), + Fatal(&'static str), +} + +impl ScriptedPersister { + fn new(outcomes: Vec) -> Self { + Self { + store_outcomes: Mutex::new(outcomes), + store_calls: AtomicUsize::new(0), + } + } + + fn store_call_count(&self) -> usize { + self.store_calls.load(Ordering::SeqCst) + } +} + +impl PlatformWalletPersistence for ScriptedPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.store_calls.fetch_add(1, Ordering::SeqCst); + // Pop the next scripted outcome. If the script runs out we + // succeed silently so post-registration writes (event-adapter + // changesets) don't muddy the count assertions. + let outcome = self + .store_outcomes + .lock() + .unwrap() + .pop() + .unwrap_or(StoreOutcome::Ok); + match outcome { + StoreOutcome::Ok => Ok(()), + StoreOutcome::Transient(msg) => Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Transient, + StringErr(msg), + )), + StoreOutcome::Fatal(msg) => Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Fatal, + StringErr(msg), + )), + } + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } +} + +/// Minimal `std::error::Error` shim for `backend_with_kind`. +#[derive(Debug)] +struct StringErr(&'static str); + +impl std::fmt::Display for StringErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} + +impl std::error::Error for StringErr {} + +struct NoopEventHandler; +impl EventHandler for NoopEventHandler {} +impl PlatformEventHandler for NoopEventHandler {} + +fn mock_sdk() -> Arc { + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .build() + .expect("mock sdk should build"), + ) +} + +fn build_manager( + persister: Arc, +) -> Arc> { + let sdk = mock_sdk(); + let handler: Arc = Arc::new(NoopEventHandler); + Arc::new(PlatformWalletManager::new(sdk, persister, handler)) +} + +/// Fixed BIP-39 seed bytes — deterministic across test runs. +fn test_seed_bytes() -> [u8; 64] { + let mut seed = [0u8; 64]; + for (i, b) in seed.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7).wrapping_add(3); + } + seed +} + +/// Reverse the script vec so `Vec::pop` consumes outcomes in +/// front-to-back order. +fn script(outcomes: Vec) -> Vec { + let mut v = outcomes; + v.reverse(); + v +} + +/// TC-CODE-018-a — Fatal store error → register undoes in-memory +/// state and surfaces `WalletRegistrationFailed`. +#[tokio::test] +async fn tc_code_018_a_fatal_store_error_undoes_in_memory_state() { + let persister = Arc::new(ScriptedPersister::new(script(vec![StoreOutcome::Fatal( + "schema constraint X violated", + )]))); + let manager = build_manager(Arc::clone(&persister)); + + let result = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + test_seed_bytes(), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await; + + let err = result.expect_err("fatal store must abort wallet registration"); + match err { + PlatformWalletError::WalletRegistrationFailed { reason, .. } => { + assert!( + reason.contains("schema constraint X violated"), + "expected backend message to be carried, got: {reason}" + ); + } + other => panic!("expected WalletRegistrationFailed, got {other:?}"), + } + + // Exactly one store attempt — fatal kind must NOT retry. + assert_eq!( + persister.store_call_count(), + 1, + "fatal store kind must not be retried" + ); + + // In-memory state has been rolled back: the wallet is not visible + // through any read API. + let wallet_ids = manager.wallet_ids().await; + assert!( + wallet_ids.is_empty(), + "registration must roll back in-memory state on fatal store; saw {wallet_ids:?}" + ); +} + +/// TC-CODE-018-b — Transient store error → one retry → success → +/// wallet is registered. +#[tokio::test] +async fn tc_code_018_b_transient_store_error_retries_once_then_succeeds() { + let persister = Arc::new(ScriptedPersister::new(script(vec![ + StoreOutcome::Transient("SQLITE_BUSY"), + StoreOutcome::Ok, + ]))); + let manager = build_manager(Arc::clone(&persister)); + + let platform_wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + test_seed_bytes(), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("transient store should be retried then succeed"); + + // Exactly two store attempts: original + one retry. + assert!( + persister.store_call_count() >= 2, + "transient store kind must trigger a retry; saw {} call(s)", + persister.store_call_count() + ); + + // Wallet is now visible through the manager. + let wallet_ids = manager.wallet_ids().await; + assert!( + wallet_ids.contains(&platform_wallet.wallet_id()), + "wallet should be registered after transient retry succeeds; saw {wallet_ids:?}" + ); +} diff --git a/packages/rs-platform-wallet/tests/remove_wallet_delete.rs b/packages/rs-platform-wallet/tests/remove_wallet_delete.rs new file mode 100644 index 00000000000..90d48299d85 --- /dev/null +++ b/packages/rs-platform-wallet/tests/remove_wallet_delete.rs @@ -0,0 +1,266 @@ +//! TC-CODE-003 — `PlatformWalletManager::remove_wallet` must call +//! `PlatformWalletPersistence::delete_wallet` so the on-disk cascade +//! actually runs. Without this wiring, in-memory state is gone but +//! the row tree stays — every subsequent reload silently resurrects +//! a "deleted" wallet. +//! +//! Covered: +//! - TC-CODE-003-1 — happy path: one `delete_wallet` call lands. +//! - TC-CODE-003-2 — fatal `delete_wallet` error does NOT abort +//! `remove_wallet`; in-memory cleanup still completes and the +//! manager surfaces the removed handle. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::Network; +use platform_wallet::changeset::{ + ClientStartState, DeleteWalletReport, PersistenceError, PersistenceErrorKind, + PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::events::{EventHandler, PlatformEventHandler}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::PlatformWalletManager; + +/// Persister that records every call it sees. `delete_wallet` can be +/// scripted with a per-call outcome queue; `store` / `flush` always +/// succeed so registration paths land cleanly. +struct RecordingPersister { + delete_calls: Mutex>, + delete_outcomes: Mutex>, + delete_count: AtomicUsize, +} + +#[allow(dead_code)] +enum DeleteOutcome { + Ok, + Transient, + Fatal, +} + +impl RecordingPersister { + fn new(outcomes: Vec) -> Self { + let mut v = outcomes; + v.reverse(); + Self { + delete_calls: Mutex::new(Vec::new()), + delete_outcomes: Mutex::new(v), + delete_count: AtomicUsize::new(0), + } + } + + fn delete_call_count(&self) -> usize { + self.delete_count.load(Ordering::SeqCst) + } + + fn delete_targets(&self) -> Vec { + self.delete_calls.lock().unwrap().clone() + } +} + +impl PlatformWalletPersistence for RecordingPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + + fn delete_wallet(&self, wallet_id: WalletId) -> Result { + self.delete_count.fetch_add(1, Ordering::SeqCst); + self.delete_calls.lock().unwrap().push(wallet_id); + let outcome = self + .delete_outcomes + .lock() + .unwrap() + .pop() + .unwrap_or(DeleteOutcome::Ok); + match outcome { + DeleteOutcome::Ok => Ok(DeleteWalletReport { + wallet_id, + backup_path: None, + rows_removed_per_table: std::collections::BTreeMap::new(), + }), + DeleteOutcome::Transient => Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Transient, + StringErr("SQLITE_BUSY"), + )), + DeleteOutcome::Fatal => Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Fatal, + StringErr("schema corruption"), + )), + } + } +} + +#[derive(Debug)] +struct StringErr(&'static str); + +impl std::fmt::Display for StringErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} + +impl std::error::Error for StringErr {} + +struct NoopEventHandler; +impl EventHandler for NoopEventHandler {} +impl PlatformEventHandler for NoopEventHandler {} + +fn mock_sdk() -> Arc { + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .build() + .expect("mock sdk should build"), + ) +} + +fn build_manager( + persister: Arc, +) -> Arc> { + let sdk = mock_sdk(); + let handler: Arc = Arc::new(NoopEventHandler); + Arc::new(PlatformWalletManager::new(sdk, persister, handler)) +} + +fn test_seed_bytes(salt: u8) -> [u8; 64] { + let mut seed = [0u8; 64]; + for (i, b) in seed.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7).wrapping_add(salt); + } + seed +} + +/// TC-CODE-003-1 — `remove_wallet` triggers exactly one +/// `persister.delete_wallet` call against the right wallet id. +#[tokio::test] +async fn tc_code_003_1_remove_wallet_calls_persister_delete_wallet() { + let persister = Arc::new(RecordingPersister::new(vec![DeleteOutcome::Ok])); + let manager = build_manager(Arc::clone(&persister)); + + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + test_seed_bytes(3), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet registration should succeed under recording persister"); + + let wallet_id = wallet.wallet_id(); + + let removed = manager + .remove_wallet(&wallet_id) + .await + .expect("remove_wallet should succeed on the happy path"); + + assert_eq!(removed.wallet_id(), wallet_id); + assert_eq!( + persister.delete_call_count(), + 1, + "expected exactly one persister.delete_wallet call; saw {}", + persister.delete_call_count() + ); + assert_eq!( + persister.delete_targets(), + vec![wallet_id], + "delete_wallet must be called with the removed wallet id" + ); + + // In-memory state really is gone. + let ids = manager.wallet_ids().await; + assert!( + !ids.contains(&wallet_id), + "wallet must be removed from the manager view" + ); +} + +/// TC-CODE-003-2 — fatal `delete_wallet` error must NOT roll back +/// the in-memory cleanup. The user wanted this wallet gone; the disk +/// failure is logged and the call still returns Ok with the handle. +#[tokio::test] +async fn tc_code_003_2_remove_wallet_completes_when_persister_fails() { + let persister = Arc::new(RecordingPersister::new(vec![DeleteOutcome::Fatal])); + let manager = build_manager(Arc::clone(&persister)); + + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + test_seed_bytes(11), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet registration should succeed"); + + let wallet_id = wallet.wallet_id(); + + let removed = manager + .remove_wallet(&wallet_id) + .await + .expect("remove_wallet must succeed even when persister.delete_wallet fails fatally"); + + assert_eq!(removed.wallet_id(), wallet_id); + assert_eq!( + persister.delete_call_count(), + 1, + "fatal delete must NOT retry; expected one call, saw {}", + persister.delete_call_count() + ); + + // In-memory state is gone — we trust the manager, not the + // persister, for the user-facing view. + let ids = manager.wallet_ids().await; + assert!( + !ids.contains(&wallet_id), + "in-memory cleanup must run regardless of persister outcome" + ); +} + +/// TC-CODE-003-3 — transient `delete_wallet` error triggers exactly +/// one retry (matching the `register_wallet` pattern from CODE-018). +#[tokio::test] +async fn tc_code_003_3_remove_wallet_retries_once_on_transient() { + // First call: transient. Second call (the retry): Ok. + let persister = Arc::new(RecordingPersister::new(vec![ + DeleteOutcome::Transient, + DeleteOutcome::Ok, + ])); + let manager = build_manager(Arc::clone(&persister)); + + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + test_seed_bytes(23), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet registration should succeed"); + + let wallet_id = wallet.wallet_id(); + + manager + .remove_wallet(&wallet_id) + .await + .expect("transient delete must be retried and succeed"); + + assert_eq!( + persister.delete_call_count(), + 2, + "transient kind must trigger exactly one retry; saw {} call(s)", + persister.delete_call_count() + ); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerIdentitySync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerIdentitySync.swift index 89d50685a52..ab636f854c9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerIdentitySync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerIdentitySync.swift @@ -117,13 +117,20 @@ extension PlatformWalletManager { }.value } - /// Add or replace the sync registry row for `identityId`. Each - /// entry in `tokenIds` becomes a watched-token row with - /// placeholder balance/contract/nonce until the next sync pass - /// populates real values. Idempotent — calling with the same - /// identity replaces the row. + /// Add or replace the sync registry row for `identityId`, bound to + /// its parent wallet. Each entry in `tokenIds` becomes a + /// watched-token row with placeholder balance/contract/nonce until + /// the next sync pass populates real values. Idempotent — calling + /// with the same identity replaces the row, including the recorded + /// parent wallet binding. + /// + /// `walletId` is required (32 bytes) — the Rust side rejects null + /// or the all-zero sentinel. Pass the parent wallet so balance + /// writes cascade through the correct `wallet_metadata → identities + /// → token_balances` chain. public func registerIdentityForTokenSync( identityId: Identifier, + walletId: Data, tokenIds: [Identifier] ) throws { guard isConfigured, handle != NULL_HANDLE else { @@ -134,6 +141,11 @@ extension PlatformWalletManager { "identityId must be 32 bytes, got \(identityId.count)" ) } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidIdentifier( + "walletId must be 32 bytes, got \(walletId.count)" + ) + } // Flatten token ids into one contiguous 32*N buffer so the // FFI can read them as back-to-back chunks. var flat = Data(capacity: 32 * tokenIds.count) @@ -146,13 +158,16 @@ extension PlatformWalletManager { flat.append(tid) } try identityId.withUnsafeBytes { idPtr in - try flat.withUnsafeBytes { tokensPtr in - try platform_wallet_manager_identity_sync_register_identity( - handle, - idPtr.bindMemory(to: UInt8.self).baseAddress, - tokensPtr.bindMemory(to: UInt8.self).baseAddress, - UInt(tokenIds.count) - ).check() + try walletId.withUnsafeBytes { walletPtr in + try flat.withUnsafeBytes { tokensPtr in + try platform_wallet_manager_identity_sync_register_identity( + handle, + idPtr.bindMemory(to: UInt8.self).baseAddress, + walletPtr.bindMemory(to: UInt8.self).baseAddress, + tokensPtr.bindMemory(to: UInt8.self).baseAddress, + UInt(tokenIds.count) + ).check() + } } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/Tokens/TokenActions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/Tokens/TokenActions.swift index 90f75ea59b2..17a8e5212c6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/Tokens/TokenActions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/Tokens/TokenActions.swift @@ -757,6 +757,7 @@ extension GroupActionMode { // `PlatformWalletManager` directly: // // try walletManager.registerIdentityForTokenSync(identityId: ..., +// walletId: ..., // tokenIds: [...]) // try await walletManager.syncIdentityTokensNow() // diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index bb1f7813a21..f1c65db5f2a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -1008,14 +1008,22 @@ struct IdentityDetailView: View { let tokenIdData: [Identifier] = idToToken.keys.compactMap { tokenIdBase58 in Data.identifier(fromBase58: tokenIdBase58) } - do { - try walletManager.registerIdentityForTokenSync( - identityId: identityBytes, - tokenIds: tokenIdData - ) - try await walletManager.syncIdentityTokensNow() - } catch { - print("⚠️ identity token sync failed: \(error)") + // Parent wallet id is required on the FFI side so balance + // writes cascade through `wallet_metadata → identities → + // token_balances`. Out-of-wallet identities (no parent + // wallet) can't use this pipeline — skip the registration + // and fall through to the display-only fetch below. + if let walletId = identity.wallet?.walletId { + do { + try walletManager.registerIdentityForTokenSync( + identityId: identityBytes, + walletId: walletId, + tokenIds: tokenIdData + ) + try await walletManager.syncIdentityTokensNow() + } catch { + print("⚠️ identity token sync failed: \(error)") + } } do {