diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 1ddc9033595..5e64a22fc38 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -1,3 +1,9 @@ [advisories] -# TODO Remove it from here -ignore = [ "RUSTSEC-2020-0071"] # advisory IDs to ignore e.g. ["RUSTSEC-2019-0001", ...] +# CI reads audit configuration from the workspace root. +# TODO: enable PR audits once existing workspace advisories are resolved. +ignore = [ + # Acknowledged 2026-06-08: bincode is unmaintained; no known exploit in this advisory. + # Storage decodes are size-bounded; the FFI asset-lock size gate is tracked in #4585. + # Replace the codec with a maintained alternative and revisit this exception. + "RUSTSEC-2025-0141", +] diff --git a/.github/workflows/tests-rs-wallet.yml b/.github/workflows/tests-rs-wallet.yml index 64cc36997d9..e6637303f60 100644 --- a/.github/workflows/tests-rs-wallet.yml +++ b/.github/workflows/tests-rs-wallet.yml @@ -201,9 +201,16 @@ jobs: --locked \ -- --no-deps -D warnings - # Mirrors the workspace job's non-shielded step for the wallet crates: - # same package subset, same `not test(~shield)` filter (shielded wallet - # tests are not run there either). + # The shielded-wallet suite (Orchard proving, viewing-key binds, note + # scans) is excluded by MODULE PATH, not by the word "shield". A + # `test(~shield)` substring match also swept up ~47 pure-logic tests + # across the three crates — input selection, FFI error codes, memo + # encoding, SQLite viewing-key rows — that cost a tenth of a second and + # have no compensating job anywhere. + # + # `--no-tests fail` is the zero-match guard: a filter that stops + # selecting anything must fail the step, not pass green. Pinned rather + # than left to nextest's default, which is a default and not a promise. - name: Run wallet tests (non-shielded) run: | cargo nextest run \ @@ -212,7 +219,8 @@ jobs: --package platform-wallet-ffi \ --all-features \ --locked \ - -E 'not test(~shield)' + --no-tests fail \ + -E 'not test(~wallet::shielded::)' env: RUST_MIN_STACK: 4194304 CARGO_PROFILE_DEV_DEBUG: "0" diff --git a/Cargo.lock b/Cargo.lock index 0f726d3c274..3c17fb0a008 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,7 @@ dependencies = [ "blake2", "cpufeatures 0.2.17", "password-hash", + "zeroize", ] [[package]] @@ -4440,6 +4441,17 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memsec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" +dependencies = [ + "getrandom 0.2.17", + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "memuse" version = "0.2.2" @@ -5322,12 +5334,15 @@ dependencies = [ "key-wallet", "keyring-core", "libc", + "memsec", "platform-wallet", "platform-wallet-storage", "proptest", "refinery", + "refinery-core", "region", "rusqlite", + "schemars 1.2.1", "serde", "serde_json", "serial_test", @@ -5336,6 +5351,7 @@ dependencies = [ "subtle", "tempfile", "thiserror 1.0.69", + "time", "tracing", "tracing-subscriber", "tracing-test", diff --git a/Cargo.toml b/Cargo.toml index 5fc7a2a957c..2ccbaf3a976 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,13 @@ opt-level = 3 opt-level = 3 [profile.dev.package.grovedb-commitment-tree] opt-level = 3 +# Same reasoning for Argon2id: a debug-built KDF is memory-hard by design, so +# an unoptimized one costs seconds per derivation and dominates any test that +# unlocks a `platform-wallet-storage` vault. `platform-wallet-storage` is the +# only workspace member that compiles argon2, so this override touches nothing +# else here despite the workspace-global syntax. +[profile.dev.package.argon2] +opt-level = 3 [profile.test.package.halo2_proofs] opt-level = 3 @@ -125,6 +132,8 @@ opt-level = 3 opt-level = 3 [profile.test.package.grovedb-commitment-tree] opt-level = 3 +[profile.test.package.argon2] +opt-level = 3 [workspace.package] diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index 5d9fc31c24a..87fc5a7977a 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -28,8 +28,8 @@ hex = "0.4" # `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. +# in `schema/platform_addrs.rs`. Storage declares only the features it uses +# directly; `platform-wallet` adds `dash-sdk/wallet` to Cargo's unified set. platform-wallet = { path = "../rs-platform-wallet", features = [ "serde", ], optional = true } @@ -46,11 +46,16 @@ rusqlite = { version = "0.38", features = [ "backup", "blob", "hooks", + "limits", "trace", ], optional = true } -refinery = { version = "0.9", default-features = false, features = [ +refinery = { version = "=0.9.2", default-features = false, features = [ "rusqlite", ], optional = true } +# The migration adapter uses refinery's supported synchronous driver traits. +# Match refinery's driver traits and applied-history timestamp types. +refinery-core = { version = "=0.9.2", default-features = false, optional = true } +time = { version = "=0.3.49", features = ["parsing"], 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. Exact-pinned to @@ -60,7 +65,17 @@ tempfile = { version = "3", optional = true } chrono = { version = "0.4", default-features = false, features = [ "clock", ], optional = true } +# Backs the migration-set fingerprint helpers in `sqlite::migrations`, which +# are test-only (`cfg(test)` / `__test-helpers`). Kept optional and pulled in +# by `__test-helpers` alone, so a shipping build does not compile a hash +# crate it never calls. sha2 = { version = "0.10", optional = true } +# `JsonSchema` for `SecretString`, enabled by `secrets` (the only feature +# under which the type exists). Reuses the workspace-locked 1.2.1. +# `default-features = false` drops the `derive` feature (we hand-write the +# impl), matching the crate's existing derive-free schemars usage so the +# lock gains no `schemars_derive` entry. +schemars = { version = "1", optional = true, default-features = false } # Secret-storage deps (gated by the `secrets` feature). RustSec-clean # pins (Smythe §7); `aes-gcm` is deliberately omitted. `keyring`'s @@ -73,6 +88,21 @@ subtle = { version = "=2.6.1", optional = true } # CSPRNG for AEAD nonces and the vault salt; exact-pinned like the rest # of the crypto stack so the random source is not the loose one. getrandom = { version = "=0.2.17", optional = true } +# Guarded allocator for `secrets::SecretString`/`SecretBytes`: every +# secret gets page-aligned, guard-paged, canary-checked, mlocked memory, +# so two live secrets can never share a page. Pure Rust (libc / +# windows-sys), no C toolchain or libsodium. Its `getrandom 0.2` and +# `windows-sys 0.45` deps are already in the workspace lock, so it adds +# no transitive crates. Exact-pinned like the rest of the +# soundness-critical stack: it holds seeds and xprivs, so a version +# change deserves a human reading the diff, not an automatic pickup. +memsec = { version = "=0.7.0", optional = true } +# Safe, cross-platform query for the host's memory page size, so +# `secrets::guarded` can refuse a host on which `memsec`'s runtime page +# rounding would blow the crate's locked-memory budget. Chosen over +# `page_size` because it is already in `Cargo.lock` (it backs the +# page-isolation tests), so this adds no new crate to the graph, and over +# a raw `libc::sysconf` because the query stays safe on Windows too. region = { version = "=3.0.2", optional = true } keyring-core = { version = "=1.0.0", optional = true } # Cross-process advisory file lock for the vault read-modify-write. @@ -80,7 +110,14 @@ keyring-core = { version = "=1.0.0", optional = true } # was removed from the sqlite arm — those tests grep for `fs2`/`fs4` # literals in this crate's source/manifest and would re-trigger on the # older crates. `fd-lock` has no such collision. -fd-lock = { version = "4.0.4", optional = true } +# LOCAL-FS ONLY: flock/LockFileEx interlock processes only on local +# filesystems; over NFS/CIFS the lock does not interlock, so a vault file +# must not be shared across hosts — steer multi-host to the OS-keyring arm. +# Exact-pinned (`=`) like the rest of the soundness-critical stack: the +# `VaultLock` unsafe drop-order argument in `secrets/file/mod.rs` is +# calibrated to fd-lock 4.0.4's guard internals; any bump must re-verify +# that the guard releases the OS lock before the backing `RwLock` frees. +fd-lock = { version = "=4.0.4", optional = true } # CLI deps (gated by the `cli` feature) clap = { version = "4", features = ["derive"], optional = true } @@ -124,19 +161,26 @@ filetime = "0.2" # Test-only: construct the `Zeroizing<[u8; 32]>` secret on an # `IdentityKeyEntry` to prove the on-disk wire shape drops it. The # production `zeroize` dep is gated behind the `secrets` feature, which -# the off-state CI build disables, so the test surface needs its own. +# the off-state build disables, so the test surface needs its own. +# NOTE: that off-state build (`--no-default-features --features +# sqlite,cli`) is a local/manual check, NOT a CI gate — no workflow runs +# it today. Keep this dev-dep regardless: it is what lets the off-state +# build succeed when someone does run it. zeroize = { version = "=1.8.2", features = ["derive"] } tracing-test = { version = "0.2", features = ["no-env-filter"] } serial_test = "3" -# `default-features = false` so the off-state CI invocation -# (`--no-default-features --features sqlite,cli`) actually exercises a +# `default-features = false` so the off-state build +# (`--no-default-features --features sqlite,cli`, a local/manual check — +# see the note above; no CI job runs it) actually exercises a # build with `secrets`/`kv` disabled — otherwise the dev-dep view would # silently re-enable the default feature set for every integration test. # Test surface is opted into explicitly: `secrets` and `kv` are listed # so the plain `cargo test -p platform-wallet-storage` invocation runs # both feature paths (the `kv`-gated `sqlite_object_metadata.rs` -# integration test and the `secrets`-gated unit tests). -platform-wallet-storage = { path = ".", default-features = false, features = ["sqlite", "cli", "secrets", "kv", "__test-helpers"] } +# integration test and the `secrets`-gated unit tests). `test-util` is here +# so `secrets_mock_store_test_util.rs` proves the feature gate from the +# outside — `cfg(test)` alone would satisfy it from within the crate. +platform-wallet-storage = { path = ".", default-features = false, features = ["sqlite", "cli", "secrets", "kv", "__test-helpers", "test-util"] } tempfile = "3" # `sqlite_hardening_3625.rs`, `sqlite_persist_roundtrip.rs`, and # `sqlite_load_reconstruction.rs` import `dash_sdk::platform::address_sync::AddressFunds`. @@ -154,6 +198,7 @@ dash-sdk = { path = "../rs-sdk", default-features = false, features = [ default = ["sqlite", "cli", "secrets", "kv"] # SQLite-backed persister (`platform_wallet_storage::sqlite`). sqlite = [ + "dep:libc", "dep:platform-wallet", "dep:serde", "dep:key-wallet", @@ -162,10 +207,11 @@ sqlite = [ "dep:dash-sdk", "dep:rusqlite", "dep:refinery", + "dep:refinery-core", + "dep:time", "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. @@ -184,17 +230,34 @@ cli = [ # crate without the crypto graph. secrets = [ "dep:argon2", + # Enable argon2's `zeroize` feature so the KDF wipes its sensitive + # intermediate state (`initial_hash`/`blockhash`) on drop, and so + # `impl Zeroize for Block` is compiled in — `derive_key` in + # `secrets/file/crypto.rs` owns the block matrix itself and relies on + # it, because argon2 0.5.3 does NOT wipe that matrix on its own. Keep + # it in the feature list (not a `default-features = false` rewrite) so + # argon2's own default features stay intact. + "argon2/zeroize", + # bincode is the producer for the Tier-2 envelope wire format and the + # three AAD encodings (`Tier2Aad`/`EntryAad`/`VerifyAad`) — see + # `secrets/wire/`. `=2.0.1` is the workspace-wide pin. + "dep:bincode", "dep:chacha20poly1305", # secrets uses serde directly (vault format + crypto envelope derive # `Serialize`/`Deserialize`); declare the dep here so # `--no-default-features --features secrets` builds without leaning # on the `sqlite` feature also having `dep:serde`. "dep:serde", + # `JsonSchema` for `SecretString` is unconditional under `secrets`: the + # schema is a bare `"type": "string"` carrying no length policy and no + # value, so there is nothing for a consumer to opt out of. + "dep:schemars", "dep:serde_json", "dep:tempfile", "dep:zeroize", "dep:subtle", "dep:getrandom", + "dep:memsec", "dep:region", "dep:keyring-core", "dep:fd-lock", @@ -203,6 +266,11 @@ secrets = [ "dep:apple-native-keyring-store", "dep:windows-native-keyring-store", ] +# `Deserialize` for `SecretString`. Deliberately DEFAULT-OFF even though +# `secrets` already pulls the `serde` DEP in: this gates the IMPL, not the +# dep, so a config type can only grow a secret field when the consumer asks +# for it. NO `Serialize` is ever provided, under any feature combination. +serde = ["dep:serde"] # Per-object-type key/value metadata API # (`platform_wallet_storage::{KvStore, KvError, ObjectId}`) plus the # SQLite-backed impl. Requires `sqlite` because the only shipped backend @@ -215,4 +283,14 @@ kv = ["sqlite"] # 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"] +__test-helpers = ["sqlite", "dep:sha2"] +# Exposes `SecretStore::file_mock` / `EncryptedFileStore::open_mock` — vault +# constructors that use the Argon2id floor for fresh-vault creation and +# per-secret wrapping; existing vaults retain their header parameters. Enable +# it in `[dev-dependencies]` ONLY. The constructors panic outside debug builds +# and this crate's own test harness. +test-util = [] +# Persists and restores Orchard viewing keys from the +# `PlatformWalletChangeSet::shielded` field. Other shielded state remains in +# the host-provided `ShieldedStore`. +shielded = ["sqlite", "platform-wallet?/shielded"] diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md index 34917c10e03..2bd2d4e7ea9 100644 --- a/packages/rs-platform-wallet-storage/README.md +++ b/packages/rs-platform-wallet-storage/README.md @@ -28,8 +28,9 @@ private-key material is ever written to that file.** [SECRETS.md](./SECRETS.md). - **Backup, restore, and migration handled for you.** Backups use SQLite's online backup API (safe under a concurrent writer); restores - run under `BEGIN EXCLUSIVE` so peers back off instead of racing the - swap; schema migrations apply automatically on every open. + use SQLite exclusive locking plus `BEGIN EXCLUSIVE` so peers back off + instead of racing the swap; schema migrations apply automatically on every + open. - **A flush contract you can build retries on.** Transient SQLite failures return a *retryable* error with the buffered changeset intact; fatal and constraint failures are reported distinctly and drop the @@ -52,6 +53,43 @@ component. The persister is `Send + Sync` and usable behind ([`SqlitePersister::commit_writes`]) are inherent methods on the persister, not part of the trait. +### Strict loading and recovery mode + +`load()` is **strict by default**: any persisted row that fails to decode, +contradicts its typed columns, or cannot be routed back to its account +aborts the load. A corrupted wallet is never handed back half-formed — +which matters most in the address pools, where a swallowed failure leaves a +previously-used address unmarked and lets it be handed out again as a fresh +receive address. + +`SqlitePersisterConfig::with_load_policy(LoadPolicy::Recovery)` opts into a +best-effort load for diagnosis and rescue: those failures are logged and +counted on `SqlitePersister::last_load_degradation()` instead of returned. +Recovery makes the persister **read-only** — `store`, `flush`, +`commit_writes`, `delete_wallet`, `prune_backups`, and the KV `put` / +`delete` all return `ReadOnlyRecoveryMode` — so a degraded projection can +never be written back over good rows. `backup_to` stays available, and +snapshot → `restore_from` → reopen strict is the intended way out. + +Recovery introduces no new tolerance: anything fatal today (an oversize +blob, an unusable schema version, a failed `PRAGMA integrity_check`) stays +fatal. The open-time gates in particular are unconditional, because `open()` +runs migrations and migrating a structurally corrupt file only deepens the +damage. Recovery also refuses `auto_backup_dir = None`, so the rescue +attempt always keeps a rollback point. + +Two sites degrade under *both* policies, because their signal cannot +distinguish corruption from a healthy wallet: a used address whose owner is +not one of the wallet's funds accounts (what a masternode-operator wallet +looks like — provider accounts are not funds accounts), and a restored +address that does not resolve against its account xpub (foreign, or +legitimately sparse past the bounded-work cap). Both re-warm on the next +sync; the balance total is exact regardless. + +`LoadDegradation` also reports `unimplemented_rows`: rows sitting in tables +`load()` has no reader for. Those are intact, merely unread, so they never +set the `degraded` flag. + ### KV / ObjectId metadata The `kv` feature adds a per-object key/value store ([`KvStore`](src/kv.rs)) @@ -79,6 +117,15 @@ database without writing custom code. --- +## Testing + +Run `cargo test -p platform-wallet-storage --all-features` for complete +crate coverage. A plain package test leaves the default-off `serde` feature +disabled, so it exercises the *absence* of `SecretString: Deserialize` +instead of the impl; every other test runs in both configurations. + +--- + ## Technical details ### Library usage @@ -102,9 +149,25 @@ to be explicit about the backend. flush, 5 s busy timeout, WAL journal, `NORMAL` synchronous, and an auto-backup dir at `/backups/auto/`. -The trait surface is `store` / `flush` / `load` / `get_core_tx_record`. -Schema migrations are append-only Rust files under `migrations/`, applied -via [`refinery`](https://github.com/rust-db/refinery) on every `open`. +Schema migrations are versioned Rust files under `migrations/`, applied via +[`refinery`](https://github.com/rust-db/refinery) on every `open`. The current +migrations V001-V007 have already been published on `v4.2-dev`: their bodies +are frozen byte-for-byte and their versions are never reassigned to different +DDL. Later migrations are append-only once published. A `CHECK` domain inside +any migration is a frozen literal, never +interpolated from a live Rust const, so adding an enum variant cannot rewrite +an applied migration's SQL. Both rules exist because refinery validates an +applied migration's checksum against the embedded migration of the same +version, and a mismatch means the database never opens again. + +An upgrade applies its pending SQL, typed legacy-state conversion, and schema +history in one transaction. The V008-V011 conversion recovers registration +discriminators from their blobs and preserves legacy pool ownership, used and +reserved states, public keys, and separately recorded derived addresses. +Malformed state belonging to an existing wallet fails the upgrade and leaves +the original schema, data, history, and pre-migration backup intact. Legacy +pool tables remain available when stopping at V008-V010 and are retired only +after their conversion succeeds. #### Flush semantics (store / flush) @@ -118,6 +181,20 @@ state. Fatal failures (integrity check, encode error, mutex poison, …) return `kind: Fatal` (or `kind: Constraint` for SQL constraint violations) and drop the buffer. +##### Connection mutex poison is permanent + +A `LockPoisoned` result means a panic occurred while the persister held its +SQLite connection lock. The connection is never recovered because it may +still contain a transaction interrupted by that panic. Drop the +`SqlitePersister` and construct a fresh instance with `SqlitePersister::open` +on the same path before attempting more work; the same-path open guard is +released when the poisoned instance is dropped. + +Every later `store()`, `flush()`, `commit_writes()`, `load()`, and +`delete_wallet()` call returns `LockPoisoned`. Detection also discards every +buffered changeset because none can be made durable through the poisoned +connection. + The full classification lives on [`WalletStorageError::is_transient`](src/sqlite/error.rs) and the companion [`WalletStorageError::persistence_kind`](src/sqlite/error.rs) that selects @@ -138,29 +215,49 @@ so one failed wallet does not hide its siblings. #### load() reconstruction -`SqlitePersister::load()` returns the base `ClientStartState` (plain struct, -two slots — no `#[non_exhaustive]`): +`SqlitePersister::load()` returns a fully-rehydrated `ClientStartState` +(plain struct — no `#[non_exhaustive]`). Both slots are populated: | Slot | Reader | Status | |---|---|---| -| `platform_addresses` | `schema::platform_addrs::load_all` (a fixed set of grouped scans over `platform_address_sync`, `platform_addresses`, and `account_registrations`, driven by the `wallet_meta::list_ids` wallet universe) | 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`). +| `platform_addresses` | `schema::platform_addrs::load_all` (a fixed set of grouped scans over `platform_address_sync`, `platform_addresses`, and `account_registrations`, driven by the `wallets::list_ids` wallet universe) | populated | +| `wallets` | per-wallet `schema::` readers (see below) | populated | + +Each `ClientStartState::wallets` entry is a **keyless** `ClientWalletStartState` +reconstructed from these per-area readers: + +| Field | Reader | +|---|---| +| `network` / `birth_height` | `schema::wallets::fetch` | +| `account_manifest` | `schema::accounts::load_state` | +| `core_state` | `schema::core_state::load_state` | +| `identity_manager` | `schema::identities::load_prekeyed` (folds persisted identities, public identity keys, and contacts into each `ManagedIdentity`) | +| `unused_asset_locks` | `schema::asset_locks::load_unconsumed` (`Consumed`-filtered — spent locks stay on disk but are never resurrected) | +| `contacts` | folded into `identity_manager` by `load_prekeyed`; the standalone field stays empty | +| `identity_keys` | folded into `identity_manager` by `load_prekeyed`; the standalone field stays empty | + +The persisted payload stores **no** `Wallet` and no key material. `load()` +reconstructs the full keyless payload, rebuilding each wallet +external-signable (`Wallet::new_external_signable` from the manifest) with +on-demand signing-key derivation through the `sign_with_mnemonic_resolver` +path. `PlatformWalletManager::load_from_persistor` then rehydrates the +manager's wallet maps from that payload, reconstructing and registering +every persisted wallet. + +What a failed decode or an inconsistent row does to the call is the load +contract, stated once in [Strict loading and recovery +mode](#strict-loading-and-recovery-mode) above — read it there. Failures +that abort surface as a typed +[`WalletStorageError`](src/sqlite/error.rs); the variants are documented on +the enum rather than listed a second time here, where the list would go +stale the next time one is added. + +The summary `tracing::info!` reports the per-call counts plus the +degradation snapshot. Its fields are the `info!` call in +`SqlitePersister::load` and are deliberately not enumerated here — an +exhaustive field list in a README drifts on the first addition. +Persisted-but-unread areas are named in `LOAD_UNIMPLEMENTED` and +row-counted from `LOAD_UNIMPLEMENTED_TABLES`. ### KV metadata API @@ -234,16 +331,20 @@ Exit codes: `0` success, `1` runtime error, `2` usage error, `3` validation failure (e.g. corrupt backup source). **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 +the destination DB in exclusive locking mode and holds a `BEGIN EXCLUSIVE` +transaction through validation and staging. This interlocks with every other +SQLite peer — sibling `SqlitePersister` handles, bare `rusqlite::Connection` +instances, the CLI — so concurrent reads and writes back off via SQLite's +`busy_timeout` instead of racing the staged work. 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. +Restore validation establishes structure, not provenance: a valid backup is +trusted as much as the live database. Protect backup directories from +untrusted replacement or modification. + ### Cargo features `default = ["sqlite", "cli", "secrets", "kv"]` @@ -254,6 +355,9 @@ the new DB takes its place. | `cli` | yes | Maintenance binary `platform-wallet-storage`. Implies `sqlite`. | | `secrets` | yes | `platform_wallet_storage::secrets` submodule — zeroizing secret wrappers (`SecretBytes`, `SecretString`), the `EncryptedFileStore` Argon2id + XChaCha20-Poly1305 vault backend, and the `default_credential_store()` OS-keyring constructor. Implements the upstream `keyring_core::api::{CredentialApi, CredentialStoreApi}` SPI. | | `kv` | yes | Per-object-type key/value metadata API (`KvStore`, `KvError`, `ObjectId`) plus its SQLite-backed impl on `SqlitePersister`. Implies `sqlite`. The `meta_*` tables are always created by V001 so DB files stay interoperable across feature combos; this gate only controls the Rust API surface. | +| `serde` | no | `Deserialize` for `SecretString`, so a config struct can carry a vault passphrase or object password straight into guarded memory. Gates the IMPL only — `secrets` compiles the serde dep regardless — and there is deliberately no `Serialize` under any combination. | +| `shielded` | no | Persists and restores Orchard viewing keys from `PlatformWalletChangeSet::shielded`. Implies `sqlite` and enables `platform-wallet/shielded`; the rest of the shielded state stays in the host-provided `ShieldedStore`. | +| `test-util` | no | `SecretStore::file_mock` / `EncryptedFileStore::open_mock` — vault constructors that use the Argon2id floor instead of the 64 MiB target, so a downstream suite does not pay a production KDF per call. `[dev-dependencies]` ONLY: the constructors panic outside debug builds. | | `__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 a @@ -280,11 +384,49 @@ SQLite side classifies its native errors through `WalletStorageError::persistence_kind` and exposes the retry decision directly via `WalletStorageError::is_transient`. +### Database trust model + +The wallet `.db` is **trusted local state**, not untrusted input. It is +expected to sit under the host application's own private directory, owned +by the same user at the same privilege level as the process reading it. An +adversary who can write arbitrary rows into the `.db` has already achieved +same-privilege local code execution, at which point the process's own +memory, its keyring entries, and its vault passphrase prompt are all +equally reachable — so hardening the read path against that adversary buys +nothing. **Threat models premised on an attacker-authored database are out +of scope for this crate.** + +The read path is nonetheless defensive, and deliberately so — against +*corruption*, not against attack: + +- Layered size limits (16 MiB per-value cap, bounded bincode decode, + 32 MiB connection backstop) keep a truncated or bit-rotted blob from + becoming an allocation the process cannot survive. +- Typed columns are cross-checked against their decoded BLOB counterparts + on every read, so a partially-applied write is caught rather than + silently trusted. +- Key/identity co-ownership is structurally enforced (compound FK plus a + trigger fallback where SQLite's own FK check goes dormant on NULL + columns), so a half-written relation fails closed. +- `PRAGMA integrity_check` and `PRAGMA foreign_key_check` run + unconditionally at open in both load policies. + +Read every one of those as bounding the blast radius of a bug, a crash +mid-write, or failing storage hardware. None of them is a security control, +and none should be cited as one. Where a bound exists only to keep work +finite — the rehydration derivation caps, for instance — its documentation +says so in those terms rather than claiming to stop an attacker. + +Backups inherit the same model: restore validation establishes structure, +not provenance. Protect backup directories as you protect the live DB. + ### Schema -The canonical schema is [`migrations/V001__initial.rs`](./migrations/V001__initial.rs) -— 23 tables of hand-written `CREATE TABLE … FOREIGN KEY …` SQL with native -`ON DELETE CASCADE`. Foreign-key enforcement is enabled and +The schema is defined by the complete [`migrations/`](./migrations/) set. +`V001` creates the 23-table base schema; later +migrations add tables and columns for address pools, metadata versions, +invitations, typed public keys, and reservation timestamps. Foreign-key +enforcement is enabled and read-back-asserted on every connection open. For the full table reference, the cascade triggers, the no-FK `meta_*` soft cascade, the orphan-metadata limitation, and the enum-domain CHECK constraints, see diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 830b7a366ef..1db74f30c76 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -11,9 +11,9 @@ chain. ## What it stores — and the boundary The persister stores **public** wallet-state material (UTXOs, transactions, -account registrations, address pools, identities, identity public keys, -contacts, asset locks, token balances, DashPay overlays, and -platform-address sync snapshots) in a SQLite database managed by +account registrations, identities, identity public keys, contacts, asset +locks, token balances, DashPay overlays, and platform-address sync +snapshots) in a SQLite database managed by [refinery](https://crates.io/crates/refinery) migrations. **No secrets are stored here.** Mnemonics, seeds, and raw private keys never @@ -23,9 +23,10 @@ see [SECRETS.md](./SECRETS.md). ## How integrity is kept -Schema evolution is version-gated by refinery. Every read-write connection turns on `PRAGMA foreign_keys = ON` at open time (`src/sqlite/conn.rs`), so every `ON DELETE CASCADE` clause is active. Deleting a `wallet_metadata` row cleans that wallet's metadata along two paths: +Schema evolution is version-gated by refinery. Every read-write connection turns on `PRAGMA foreign_keys = ON` at open time (`src/sqlite/conn.rs`), so every `ON DELETE CASCADE` clause is active. Deleting a `wallets` row cleans that wallet's metadata along three paths: - **`wallet_id`-scoped meta** (`meta_wallet`, `meta_contact`, `meta_platform_address`) carries a `wallet_id` column, so `cascade_meta_on_wallet_delete` brooms it directly — regardless of the lifecycle state of any typed parent and even for rows written ahead of (or without) a typed parent. +- **metadata-version rows** (`meta_data_versions`) carry a `wallet_id` column and are cleaned directly by `cascade_meta_data_versions_on_wallet_delete`. - **identity-scoped meta** (`meta_identity`, `meta_token`) carries no `wallet_id` — only `identity_id` (+ `token_id`). It is cleaned by `cascade_meta_on_identity_delete` (AFTER DELETE ON `identities`), which fires for the wallet's own identities when the FK cascade removes them on a wallet delete. ### Orphan metadata and future garbage collection @@ -37,24 +38,21 @@ Any `meta_*` row whose parent object does not exist — because it was never cre A future garbage-collection pass is expected to reap orphan metadata — rows with no live parent object older than approximately one week — but no such GC is implemented yet. Callers should not rely on orphan metadata persisting forever, nor assume it will be cleaned up promptly. `meta_global` is intentionally parentless and always survives. -The 23 tables are split into five domain diagrams below. `WALLET_METADATA` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section. +The tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. The diagrams cover 21 of V001's 23 tables; `pending_contact_crypto` and `ignored_senders` appear in the [Tables](#tables) section but are not diagrammed. They show the V001 tables as amended in place by every later migration that changes one of them (the current `core_utxos`, `core_transactions`, `platform_addresses`, and `asset_locks` shapes). Nine tables added by later migrations are not yet diagrammed here: `core_address_pool`, `meta_data_versions`, and `meta_store_generation` (V009, plus its V010–V011 `core_address_pool` columns), `invitations` (V003), `shielded_viewing_keys` (V013), `dpns_name_states` (V005), `tracked_masternodes` (V006), and the `identity_scan_states` / `identity_scan_failed_indices` pair (V017) — see the [Migrations](#migrations) log for what each adds in the meantime. ## Diagram 1 — Core / L1 (Bitcoin/Dash layer) -Account registrations, address-pool snapshots, transactions, UTXOs, instant locks, derived addresses, and SPV sync state. +Account registrations, transactions, UTXOs, instant locks, and SPV sync state. ```mermaid erDiagram - WALLET_METADATA ||--o{ ACCOUNT_REGISTRATIONS : "registers" - WALLET_METADATA ||--o{ ACCOUNT_ADDRESS_POOLS : "snapshots" - WALLET_METADATA ||--o{ CORE_TRANSACTIONS : "records" - WALLET_METADATA ||--o{ CORE_UTXOS : "owns" - WALLET_METADATA ||--o{ CORE_INSTANT_LOCKS : "holds" - WALLET_METADATA ||--o{ CORE_DERIVED_ADDRESSES : "derives" - WALLET_METADATA ||--o| CORE_SYNC_STATE : "tracks" - CORE_TRANSACTIONS ||--o{ CORE_UTXOS : "spends" - - WALLET_METADATA { + WALLETS ||--o{ ACCOUNT_REGISTRATIONS : "registers" + WALLETS ||--o{ CORE_TRANSACTIONS : "records" + WALLETS ||--o{ CORE_UTXOS : "owns" + WALLETS ||--o{ CORE_INSTANT_LOCKS : "holds" + WALLETS ||--o| CORE_SYNC_STATE : "tracks" + + WALLETS { BLOB wallet_id PK "32-byte WalletId" TEXT network "mainnet | testnet | devnet | regtest" INTEGER birth_height "SPV scan start height" @@ -62,27 +60,22 @@ erDiagram ACCOUNT_REGISTRATIONS { BLOB wallet_id PK - TEXT account_type PK "standard | coinjoin | identity_registration | ..." - INTEGER account_index PK - BLOB account_xpub_bytes "bincode-encoded AccountRegistrationEntry" - } - - ACCOUNT_ADDRESS_POOLS { - BLOB wallet_id PK - TEXT account_type PK + TEXT account_type PK "standard_bip44 | ... | provider_operator | provider_platform" INTEGER account_index PK - TEXT pool_type PK "external | internal | absent | absent_hardened" - BLOB snapshot_blob "bincode-encoded AccountAddressPoolEntry" + INTEGER key_class PK "discriminator; sentinel 0 unless PlatformPayment" + BLOB user_identity_id PK "discriminator; sentinel zeroblob(32) unless DashPay" + BLOB friend_identity_id PK "discriminator; sentinel zeroblob(32) unless DashPay" + BLOB account_xpub_bytes "bincode: AccountRegistrationEntry, or ProviderKeyAccountEntry for provider_* types" } CORE_TRANSACTIONS { BLOB wallet_id PK BLOB txid PK "32-byte Txid" INTEGER height "NULL if unconfirmed" - BLOB block_hash "NULL if unconfirmed" - INTEGER block_time "NULL if unconfirmed" - INTEGER finalized "0 | 1" - BLOB record_blob "bincode-encoded TransactionRecord" + BLOB block_hash "NULL on height-only rows and while unconfirmed" + INTEGER block_time "NULL on height-only rows and while unconfirmed" + INTEGER finalized "0 | 1; always 0 on height-only rows" + BLOB record_blob "NULL for height-only UTXO rows" } CORE_UTXOS { @@ -90,8 +83,7 @@ erDiagram BLOB outpoint PK "bincode-encoded OutPoint" INTEGER value "satoshis" BLOB script "scriptPubKey bytes" - INTEGER height "funding height, 0 if unconfirmed; NULL only for an unmaterialised sweep placeholder" - INTEGER account_index + INTEGER is_sweep_placeholder "1 until funding arrives" INTEGER spent "0 | 1" BLOB spent_in_txid "set by apply_sweep for an unresolved held input; else NULL" INTEGER winner_mined_height "V007: sweep winner's mined height; NULL when unstamped or materialised" @@ -103,41 +95,29 @@ erDiagram BLOB islock_blob "bincode-encoded InstantLock" } - CORE_DERIVED_ADDRESSES { - BLOB wallet_id PK - TEXT account_type PK - TEXT address PK "bech32 / Base58 address string" - INTEGER account_index - TEXT derivation_path "pool_type/derivation_index" - INTEGER used "0 | 1" - } - CORE_SYNC_STATE { BLOB wallet_id PK "one row per wallet" INTEGER last_processed_height "NULL until first block processed" INTEGER synced_height "NULL until first sync" + BLOB last_applied_chain_lock "encoded ChainLock for rehydration" INTEGER chainlock_height "V007: monotonic-max applied chainlock height; NULL until one is applied" } ``` -> Note: the `CORE_TRANSACTIONS → CORE_UTXOS` edge shown above is enforced by the -> `setnull_core_utxos_on_tx_delete` SQLite trigger, not a declared `FOREIGN KEY`. -> A native `ON DELETE SET NULL` composite FK would also null the NOT NULL `wallet_id` -> column — the trigger nulls only `spent_in_txid`, preserving the intended semantics. - ## Diagram 2 — Identities + DashPay (Platform L2 identity tree) -Platform identities, their public keys, token balances, and DashPay profiles/payments. Identity-owned tables have no direct `wallet_id` column; cascade flows `wallet_metadata → identities → child`. +Platform identities, their public keys, token balances, and DashPay profiles/payments. Most identity-owned tables have no direct `wallet_id` column and cascade via `wallets → identities → child`; `identity_keys` is the exception — it carries its own `wallet_id` column and two `ON DELETE CASCADE` FKs (one to `wallets`, one to `identities`). ```mermaid erDiagram - WALLET_METADATA ||--o{ IDENTITIES : "parents" + WALLETS ||--o{ IDENTITIES : "parents" + WALLETS ||--o{ IDENTITY_KEYS : "owns" IDENTITIES ||--o{ IDENTITY_KEYS : "has" IDENTITIES ||--o{ TOKEN_BALANCES : "holds" IDENTITIES ||--o| DASHPAY_PROFILES : "has" IDENTITIES ||--o{ DASHPAY_PAYMENTS_OVERLAY : "overlays" - WALLET_METADATA { + WALLETS { BLOB wallet_id PK "32-byte WalletId" TEXT network INTEGER birth_height @@ -146,7 +126,7 @@ erDiagram IDENTITIES { BLOB identity_id PK "32-byte Platform Identifier" BLOB wallet_id FK "NULL = orphan identity (no parent wallet yet)" - INTEGER wallet_index "BIP-32 index; NULL for out-of-wallet identities" + INTEGER identity_index "BIP-32 index; NULL for out-of-wallet identities" BLOB entry_blob "bincode-encoded IdentityEntry" INTEGER tombstoned "0 | 1 (logical delete)" } @@ -154,8 +134,10 @@ erDiagram IDENTITY_KEYS { BLOB identity_id PK INTEGER key_id PK "KeyID" + BLOB wallet_id FK "nullable; denormalised copy of identities.wallet_id, not part of the key" BLOB public_key_blob "bincode-encoded IdentityKeyWire (public material only)" BLOB public_key_hash "20-byte HASH160 of the key" + BLOB derivation_blob "reserved typed projection; always NULL today" } TOKEN_BALANCES { @@ -183,10 +165,10 @@ One unified table for all three states of a DashPay contact relationship — the ```mermaid erDiagram - WALLET_METADATA ||--o{ CONTACTS : "has" + WALLETS ||--o{ CONTACTS : "has" IDENTITIES ||--o{ CONTACTS : "relates" - WALLET_METADATA { + WALLETS { BLOB wallet_id PK "32-byte WalletId" TEXT network INTEGER birth_height @@ -207,6 +189,7 @@ erDiagram TEXT note "established-only (NULL when pending)" INTEGER is_hidden "established-only (NULL when pending)" BLOB accepted_accounts "bincode-encoded Vec u32; established-only" + INTEGER payment_channel_broken "established-only; NULL = false" INTEGER updated_at "unixepoch() default" } ``` @@ -215,7 +198,7 @@ erDiagram > are NOT declared `FOREIGN KEY` columns. The relationship to `IDENTITIES` shown above is > logical — enforced at the application layer, not by SQLite constraints. A pending row is > `sent` XOR `received` and carries only the matching request blob; an `established` row sets -> both request blobs plus the four metadata columns. +> both request blobs plus the five metadata columns. ## Diagram 4 — Platform addresses + Asset locks (Platform L2 funding) @@ -223,11 +206,11 @@ Platform P2PKH address pool with its sync watermark, and the asset-lock lifecycl ```mermaid erDiagram - WALLET_METADATA ||--o{ PLATFORM_ADDRESSES : "tracks" - WALLET_METADATA ||--o| PLATFORM_ADDRESS_SYNC : "syncs" - WALLET_METADATA ||--o{ ASSET_LOCKS : "issues" + WALLETS ||--o{ PLATFORM_ADDRESSES : "tracks" + WALLETS ||--o| PLATFORM_ADDRESS_SYNC : "syncs" + WALLETS ||--o{ ASSET_LOCKS : "issues" - WALLET_METADATA { + WALLETS { BLOB wallet_id PK "32-byte WalletId" TEXT network INTEGER birth_height @@ -240,6 +223,7 @@ erDiagram INTEGER address_index INTEGER balance "credits" INTEGER nonce + INTEGER as_of_height "Platform-block-height pin (V002); DEFAULT 0 = unknown provenance" } PLATFORM_ADDRESS_SYNC { @@ -252,7 +236,7 @@ erDiagram ASSET_LOCKS { BLOB wallet_id PK BLOB outpoint PK "bincode-encoded OutPoint" - TEXT status "built | broadcast | is_locked | chain_locked | consumed" + TEXT status "built | broadcast | is_locked | chain_locked | consumed | recovered_from_chain (V004)" INTEGER account_index INTEGER identity_index INTEGER amount_duffs @@ -269,7 +253,7 @@ table per [`ObjectId`](./src/kv.rs) variant. `meta_global` has no parent and survives wallet deletion. The other five carry **no foreign key**: metadata may be written before its parent object is synced into its typed table. `AFTER DELETE` triggers provide a soft cascade so metadata -never outlives its wallet. Deleting a `wallet_metadata` row brooms every +never outlives its wallet. Deleting a `wallets` row brooms every wallet-scoped `meta_*` row by `wallet_id` directly, and the FK cascade through `identities` brooms the identity-scoped `meta_*` rows by `identity_id`; both legs key on the id alone, so cleanup is independent @@ -280,9 +264,9 @@ edges below denote trigger-based cleanup, not an FK relationship. ```mermaid erDiagram - WALLET_METADATA ||..o{ META_WALLET : "trigger cleanup (by wallet_id)" - WALLET_METADATA ||..o{ META_CONTACT : "trigger cleanup (by wallet_id)" - WALLET_METADATA ||..o{ META_PLATFORM_ADDRESS : "trigger cleanup (by wallet_id)" + WALLETS ||..o{ META_WALLET : "trigger cleanup (by wallet_id)" + WALLETS ||..o{ META_CONTACT : "trigger cleanup (by wallet_id)" + WALLETS ||..o{ META_PLATFORM_ADDRESS : "trigger cleanup (by wallet_id)" IDENTITIES ||..o{ META_IDENTITY : "trigger cleanup (by identity_id)" IDENTITIES ||..o{ META_TOKEN : "trigger cleanup (by identity_id)" @@ -293,7 +277,7 @@ erDiagram } META_WALLET { - BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + BLOB wallet_id PK "no FK; trigger cleanup on wallets delete" TEXT key PK BLOB value INTEGER updated_at @@ -315,7 +299,7 @@ erDiagram } META_CONTACT { - BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + BLOB wallet_id PK "no FK; trigger cleanup on wallets delete" BLOB owner_id PK BLOB contact_id PK TEXT key PK @@ -324,7 +308,7 @@ erDiagram } META_PLATFORM_ADDRESS { - BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + BLOB wallet_id PK "no FK; trigger cleanup on wallets delete" BLOB address PK TEXT key PK BLOB value @@ -344,7 +328,7 @@ erDiagram ## Tables -### `wallet_metadata` +### `wallets` Root anchor for every per-wallet table. Deleting a row cascades to all direct children; identity-owned children cascade through `identities`. @@ -356,34 +340,64 @@ direct children; identity-owned children cascade through `identities`. ### `account_registrations` One row per account registered on a wallet (xpub + account type + index). -The `account_xpub_bytes` blob carries the full `AccountRegistrationEntry`; -the typed `account_type` / `account_index` columns mirror it for SQL -lookups without blob decoding. - -- PK: `(wallet_id, account_type, account_index)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. - -### `account_address_pools` - -Address-pool snapshot per `(wallet, account, pool_type)`. `pool_type` is -one of `external`, `internal`, `absent`, `absent_hardened`. - -- PK: `(wallet_id, account_type, account_index, pool_type)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +The `account_xpub_bytes` blob carries the full `AccountRegistrationEntry` +for secp256k1 accounts, or a `ProviderKeyAccountEntry` for the two +provider key-material account types (`'provider_operator'` BLS, +`'provider_platform'` EdDSA — index-less, always `account_index = 0`); the +typed `account_type` / `account_index` columns mirror the common fields for +SQL lookups without blob decoding. + +- PK: `(wallet_id, account_type, account_index, key_class, user_identity_id, + friend_identity_id)` — the last three columns discriminate accounts that + otherwise share `(account_type, account_index)`: PlatformPayment's + `key_class` axis, and the DashPay `(user_identity_id, friend_identity_id)` + pair. Sentinel `0` / `zeroblob(32)` default for account types without + that axis (including the provider key-material types). +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. + +### `pending_contact_crypto` + +Deferred, signer-dependent contact cryptography operations. The owner, +contact, and operation kind form the deduplication key; `payload` carries the +public-only ciphertext and key-index data needed when a signer becomes +available. + +- PK: `(wallet_id, owner_identity_id, contact_id, kind)`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. +- `kind` CHECK: sourced from + `sqlite::schema::pending_contact_crypto::KIND_LABELS`. ### `core_transactions` -One row per transaction the wallet has seen. `height`, `block_hash`, and -`block_time` are NULL while the transaction is unconfirmed. `finalized` -is `1` once block context is present. +One row per transaction the wallet has seen. A transaction whose record has not +been persisted still gets a row, carrying only the confirmation height its +UTXOs report — all UTXOs of that transaction share it. `height` is the sole +persisted UTXO confirmation-height source: `NULL` is the sole unconfirmed +marker, and height `0` is a legal confirmed height. On height-only rows, +`record_blob`, `block_hash`, and `block_time` are NULL and `finalized` is `0`; +`finalized` is meaningful only on blob-bearing rows. A blob-bearing row always +takes precedence over a height-only write, even when the latter reports a +different height. `WalletStorageError::CoreTransactionEntryMismatch` detects +typed/blob disagreement. The blob is authoritative and the typed columns are +left exactly as found — **no read path writes**, in either load policy +(pinned by `get_core_tx_record_never_writes`). The policy decides only what +the disagreement costs: under `LoadPolicy::Strict` it aborts the load, under +`LoadPolicy::Recovery` it is logged and counted as +`LoadSite::CoreTransactionColumnDrift` while the blob's values are used. +Repairing the drifted columns is a writer's job, on the next write of that +row. - PK: `(wallet_id, txid)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. - Index: `idx_core_transactions_height(wallet_id, height)`. ### `core_utxos` -One row per UTXO, spent or unspent. `spent_in_txid` is written only by +One row per UTXO, spent or unspent. Owning-account identity is derived from +`core_address_pool` while loading wallet state, and confirmation height is +derived from `core_transactions.height`. + +`spent_in_txid` is written only by `apply_sweep`, naming the winner that took an input a swept loser claimed but this store had no released record for. It is set to NULL by a trigger when its referenced `core_transactions` row is deleted (instead of a native @@ -391,8 +405,7 @@ when its referenced `core_transactions` row is deleted (instead of a native column) — and by a later sweep that releases the same outpoint. What gates the funding UTXO's own later upsert (`execute_upsert_utxo`) is -the row's shape, not that link: a never-materialised held row (`height` -NULL, `spent = 1` — the placeholder `apply_sweep` writes for an input whose +the row's shape, not that link: a never-materialised held row (`is_sweep_placeholder = 1`, `spent = 1` — the placeholder `apply_sweep` writes for an input whose funding this store had not seen) stays spent when the funding arrives, with or without a `spent_in_txid` (the trigger can null it underneath a live hold). A materialised row follows the wallet: it knows the coin, any @@ -415,10 +428,10 @@ clears the stamp, because a materialised row is the wallet's own coin held spent and is permanently outside the collector's reach. - PK: `(wallet_id, outpoint)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. - Index: `idx_core_utxos_spent(wallet_id, spent)`. - Index: `idx_core_utxos_unmaterialized(wallet_id, winner_mined_height) - WHERE height IS NULL` (V007) — covers exactly the unmaterialised rows, so + WHERE is_sweep_placeholder = 1` — covers exactly the unmaterialised rows, so the collector's per-round scan touches tombstones rather than the wallet's full spent history. @@ -428,22 +441,14 @@ Instant-lock blobs for transactions that are broadcast but not yet finalized. Rows are removed when the transaction becomes confirmed. - PK: `(wallet_id, txid)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. - -### `core_derived_addresses` - -Address-to-account-index map. Written before UTXOs in the same -transaction so the UTXO writer can resolve `account_index` by address. - -- PK: `(wallet_id, account_type, address)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. -- Index: `idx_core_derived_addresses_addr(wallet_id, address)`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. ### `core_sync_state` -One row per wallet, holding monotonically-advancing SPV sync watermarks. -`last_processed_height` and `synced_height` are NULL until the first -block is processed. +One row per wallet, holding monotonically-advancing SPV sync watermarks and +the last applied ChainLock. `last_processed_height` and `synced_height` are +NULL until the first block is processed. `last_applied_chain_lock` is NULL +until a ChainLock has been applied and flushed. `chainlock_height` (V007) mirrors `CoreChangeSet::last_applied_chain_lock` as a monotonic max — the height alone, which this store previously dropped. @@ -453,7 +458,11 @@ tombstone is never collected before a chainlock has been persisted, matching upstream's "no-op until a chainlock has been applied". - PK: `wallet_id` (single-row-per-wallet). -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. +- `last_applied_chain_lock BLOB` — bincode-encoded + `dashcore::ephemerealdata::chain_lock::ChainLock`; used during rehydration + to restore `WalletMetadata::last_applied_chain_lock` so asset-lock proof + generation can use the cached ChainLock from before a restart. ### `identities` @@ -463,19 +472,43 @@ NULL means the identity was written before a parent wallet was registered marks a logical delete; the row is retained for cascade integrity. - PK: `identity_id`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE` (nullable). +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE` (nullable). - Index: `idx_identities_wallet(wallet_id)`. +- Index: `idx_identities_wallet_identity(wallet_id, identity_id)` UNIQUE — + parent key for `identity_keys`' compound FK (SQLite requires the + referenced columns to carry a UNIQUE index); adds no new restriction, + since `identity_id` is already `PRIMARY KEY`. ### `identity_keys` Public identity keys only — no private material. The `public_key_blob` is a custom wire format (`IdentityKeyWire`) that pre-encodes the `IdentityPublicKey` via bincode 2 native `Encode/Decode` -to work around a serde-tag incompatibility. +to work around a serde-tag incompatibility. `derivation_blob` is a +reserved column for a future typed projection and is always NULL today +(derivation indices live inside `public_key_blob`). + +`identities` keys on `identity_id` alone, so an identity has exactly one +owning wallet; `identity_keys.wallet_id` is a nullable, denormalised copy +of that owner, not a discriminator. The PK was narrowed from the wider +`(wallet_id, identity_id, key_id)` — that shape let the same key exist +twice under two different scopes, a state the domain layer +(`IdentityKeysChangeSet`, keyed `(identity_id, key_id)`) cannot express, +and was the enabling condition for duplicate-row corruption. NULL is the +canonical "owned by no wallet" scope, matching `identities.wallet_id`; +because SQLite's default `MATCH SIMPLE` skips FK enforcement entirely when +any child-key column is NULL, a NULL-scoped row's FKs are both dormant — +the `identity_keys_null_scope_requires_unowned_identity{,_on_update}` +triggers (`migrations/V016__identity_keys_null_scope_requires_existing_identity.rs`) +are the only guard against a NULL-scoped key naming a wallet-owned identity. + +A trigger change belongs in a new migration that drops and recreates the +trigger; an applied migration is never edited. - PK: `(identity_id, key_id)`. -- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. -- Index: `idx_identity_keys_identity(identity_id)`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE` (nullable; belt-and-braces — already implied by the compound FK below). +- FK: `(wallet_id, identity_id) → identities(wallet_id, identity_id) ON DELETE CASCADE` (compound; a key may only be filed under the wallet that owns its identity). +- Index: `idx_identity_keys_wallet_identity(wallet_id, identity_id)`. ### `contacts` @@ -484,23 +517,41 @@ All DashPay contact relationships in one table, keyed by lifecycle counterparty. A pending relationship is `sent` (we sent the request) XOR `received` (we received it) and carries only the matching request blob; an `established` relationship carries both `outgoing_request` and -`incoming_request` plus the four metadata columns (`alias`, `note`, -`is_hidden`, `accepted_accounts`, NULL while pending). The request columns -hold a bincode-encoded `ContactRequest`; `accepted_accounts` holds a -bincode-encoded `Vec`. +`incoming_request` plus the five metadata columns (`alias`, `note`, +`is_hidden`, `accepted_accounts`, `payment_channel_broken`; NULL while +pending). The request columns hold a bincode-encoded `ContactRequest`; +`accepted_accounts` holds a bincode-encoded `Vec`. +`payment_channel_broken` is nullable and read as false when NULL; it is set +when external-account registration permanently fails for a contact and +cleared on a superseding rotation. - PK: `(wallet_id, owner_id, contact_id)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. - `state` CHECK: sourced from `sqlite::schema::contacts::CONTACT_STATE_LABELS`. +### `ignored_senders` + +Reversible per-sender DashPay mute records. Each row suppresses all incoming +contact requests from `sender_id` for one owner identity until the row is +deleted; `ignored_at` records when the mute was applied. + +- PK: `(wallet_id, owner_id, sender_id)`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. +- No enum-domain CHECK column. + ### `platform_addresses` Platform P2PKH address pool entries. `address` stores the 20-byte HASH160; `balance` and `nonce` are the last-synced values from the -Platform layer. +Platform layer. `as_of_height` (V002) is the Platform-block-height pin +reconciling proof-attested absolute balances against the recent/compacted +delta stream: a delta recorded at or below the pin is already included in +the absolute and must not be re-applied. `DEFAULT 0` on pre-existing rows +means "unknown provenance" — every delta applies and any pinned absolute +supersedes them. - PK: `(wallet_id, address)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. ### `platform_address_sync` @@ -508,23 +559,34 @@ Per-wallet watermark for platform address sync. All three height/timestamp fields advance monotonically (new values are `max(current, incoming)`). - PK: `wallet_id` (single-row-per-wallet). -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. ### `asset_locks` Lifecycle tracking for asset-lock outpoints. `status` is a queryable -text column; `lifecycle_blob` carries the full `AssetLockEntry`. Consumed -locks are removed via `AssetLockChangeSet::removed`, not retained with a -consumed status. +text column (`built | broadcast | is_locked | chain_locked | consumed`, +widened by V004 to add `recovered_from_chain` — the restore-scan +reconstruction status for a lock rebuilt from a chain-locked on-chain +record, Core finality proven but Platform-side consumption unknown); +`lifecycle_blob` carries the full `AssetLockEntry`. Consumed +locks are **retained permanently** with `status = 'consumed'` (an upsert, +never a `DELETE` — they are not routed through `AssetLockChangeSet::removed`), +so the full lifecycle history stays on disk and remains visible via the +unfiltered inspection reader (`schema::asset_locks::list_active`). The +rehydration feed reads through `schema::asset_locks::load_unconsumed`, which +filters at the SQL level (`status NOT IN ('consumed')`), so a spent one-shot +lock is never resurrected as actionable. - PK: `(wallet_id, outpoint)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. ### `token_balances` -Per-identity token balance cache, keyed by `(identity_id, token_id)`. -Cascade flows `wallet_metadata → identities → token_balances` through the +Per-identity token-balance rows, keyed by `(identity_id, token_id)`. +Cascade flows `wallets → identities → token_balances` through the nullable `identities.wallet_id` link; no direct `wallet_id` column exists. +`apply` writes these rows, but `load()` does not return them today; +`IdentitySyncManager` rebuilds the canonical token-balance copy from Platform. - PK: `(identity_id, token_id)`. - FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. @@ -533,6 +595,8 @@ nullable `identities.wallet_id` link; no direct `wallet_id` column exists. At most one DashPay profile blob per identity. `None` profile maps to a DELETE rather than a NULL blob — the row is absent, not nulled. +`apply` writes these rows, but `load()` does not return them today; +`DashPaySyncManager` rebuilds the canonical profile from Platform. - PK: `identity_id` (single-row-per-identity). - FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. @@ -542,6 +606,9 @@ DELETE rather than a NULL blob — the row is absent, not nulled. Payment overlay entries for DashPay, keyed by transaction-level `payment_id` string. Cascade flows through `identities` as with `token_balances`. +`apply` writes these rows, but `load()` does not return them today; +`DashPaySyncManager` rebuilds the canonical payment-overlay state from +Platform. - PK: `(identity_id, payment_id)`. - FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. @@ -563,13 +630,18 @@ Unlike every other per-wallet table, the five typed `meta_*` tables carry host apps can attach metadata independently of sync ordering (and a global-config persister can write to typed scopes whose parent tables stay empty). Cleanup is instead a soft cascade. Deleting a -`wallet_metadata` row fires a wallet-rooted `AFTER DELETE` trigger that +`wallets` row fires a wallet-rooted `AFTER DELETE` trigger that brooms the wallet-scoped tables (`meta_wallet`, `meta_contact`, -`meta_platform_address`) by `wallet_id`, and the FK cascade through -`identities` fires a per-identity trigger that brooms `meta_identity` + -`meta_token` by `identity_id`. Both legs key on the id alone, so a wallet -delete cleans its metadata transitively whether or not the typed parent -was ever written and regardless of any contact's lifecycle state. +`meta_platform_address`) by `wallet_id` — unconditionally, regardless of +whether the typed parent row was ever written or any contact's lifecycle +state. The identity-scoped tables (`meta_identity`, `meta_token`) are +broomed by a *different* leg: the `wallets → identities` FK cascade deletes +each linked `identities` row, and a per-identity `AFTER DELETE` trigger then +brooms by `identity_id`. That leg fires only for identities the wallet +actually owns, so it cleans `meta_token` even when no `token_balances` row +ever existed — but identity-scoped metadata for an identity whose +`identities` row was never written (or is not linked to this wallet) +survives the delete as an orphan (see the orphan-metadata limitation above). Additional triggers handle direct deletes of a single `token_balances`, `contacts`, or `platform_addresses` row. @@ -585,7 +657,7 @@ Global metadata with no parent — survives every wallet delete. Per-wallet metadata. Writable before the wallet exists. - PK: `(wallet_id, key)`. -- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`). +- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallets`, by `wallet_id`). #### `meta_identity` @@ -606,7 +678,7 @@ Per-token-balance metadata. Writable before the token balance exists. Per-contact metadata for any lifecycle state. Writable before the contact exists. - PK: `(wallet_id, owner_id, contact_id, key)`. -- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`) on a wallet delete, plus `cascade_meta_contact_on_contact_delete` (AFTER DELETE ON `contacts`, any state) for a direct contact delete. +- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallets`, by `wallet_id`) on a wallet delete, plus `cascade_meta_contact_on_contact_delete` (AFTER DELETE ON `contacts`, any state) for a direct contact delete. #### `meta_platform_address` @@ -614,30 +686,29 @@ Per-platform-address metadata. `address` is an opaque `BLOB`. Writable before the address exists. - PK: `(wallet_id, address, key)`. -- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`) on a wallet delete, plus `cascade_meta_platform_address_on_address_delete` (AFTER DELETE ON `platform_addresses`) for a direct address delete. +- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallets`, by `wallet_id`) on a wallet delete, plus `cascade_meta_platform_address_on_address_delete` (AFTER DELETE ON `platform_addresses`) for a direct address delete. ## Enum-domain CHECK constraints -Six TEXT columns carry a `CHECK (col IN (...))` clause whose IN-list is -built at migration time from `pub(crate) const *_LABELS` arrays declared -next to each writer function. Five mirror an upstream Rust enum; the -sixth (`contacts.state`) is a synthetic lifecycle label naming which -`ContactChangeSet` slot a row came from: +Five TEXT columns carry a `CHECK (col IN (...))` across five enum +domains. The IN-list is built at migration time from +`pub(crate) const *_LABELS` arrays declared next to each writer function. +Four domains mirror a Rust enum; the fifth (`contacts.state`) +is a synthetic lifecycle label naming which `ContactChangeSet` slot a row +came from: | Table | Column | Source-of-truth const | |---|---|---| -| `wallet_metadata` | `network` | `sqlite::schema::wallet_meta::NETWORK_LABELS` | +| `wallets` | `network` | `sqlite::schema::wallets::NETWORK_LABELS` | | `account_registrations` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | -| `account_address_pools` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | -| `account_address_pools` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` | -| `core_derived_addresses` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | +| `pending_contact_crypto` | `kind` | `sqlite::schema::pending_contact_crypto::KIND_LABELS` | | `asset_locks` | `status` | `sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS` | | `contacts` | `state` | `sqlite::schema::contacts::CONTACT_STATE_LABELS` | The const arrays are the single source of truth shared by the writer mapping functions (`network_to_str`, `account_type_db_label`, -`pool_type_db_label`, `status_str`, `contact_state_db_label`) and the -migration's CHECK clauses. +`kind_db_label`, `status_str`, `contact_state_db_label`) and the migration's CHECK +clauses. Per-module `*_labels_match_enum` unit tests enforce set-equality between each const and the writer's codomain — drift (a renamed/added upstream variant) fails the test rather than landing as silent garbage @@ -646,11 +717,11 @@ in this document; the source files are canonical. ### Upstream-enum coupling -Three of the persisted enums live in the external `rust-dashcore` -crate (`key_wallet::Network`, `key_wallet::account::AccountType`, -`key_wallet::managed_account::address_pool::AddressPoolType`); the -fourth (`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`) -is in-tree and carries a `# Schema coupling` rustdoc block. +Two persisted enums live in the external `rust-dashcore` crate +(`key_wallet::Network`, `key_wallet::account::AccountType`). The other two +(`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus` and +`platform_wallet::changeset::PendingContactCryptoKind`) live in-tree; +`AssetLockStatus` also carries a `# Schema coupling` rustdoc block. Because the upstream definitions cannot be edited from this repository, the coupling is enforced from the local side instead, by three @@ -666,23 +737,28 @@ mechanisms working together: TODO(rust-dashcore): once the upstream `key_wallet` crate is vendored or the project gains push access there, mirror the in-tree -`AssetLockStatus` `# Schema coupling` doc block on the three upstream +`AssetLockStatus` `# Schema coupling` doc block on the two upstream enums so a developer editing them upstream sees the constraint without having to grep this repo. ## Foreign-key conventions - All direct-child `wallet_id` columns are `BLOB(32)` references to - `wallet_metadata.wallet_id` with `ON DELETE CASCADE`. + `wallets.wallet_id` with `ON DELETE CASCADE`. - `identities.wallet_id` is the single nullable FK: NULL means orphan (no parent wallet registered yet). The orphan-to-parented promotion uses `COALESCE(identities.wallet_id, excluded.wallet_id)` on upsert. -- Identity-owned tables (`identity_keys`, `token_balances`, - `dashpay_profiles`, `dashpay_payments_overlay`) have no `wallet_id` - column. Cascade reaches them via `identities(identity_id)`. -- `core_utxos.spent_in_txid` is cleared by the `setnull_core_utxos_on_tx_delete` - trigger rather than a native `ON DELETE SET NULL` FK, because SQLite would null - every column of a composite FK on SET NULL — including the NOT NULL `wallet_id`. +- Identity-owned tables (`token_balances`, `dashpay_profiles`, + `dashpay_payments_overlay`) have no `wallet_id` column. Cascade reaches + them via `identities(identity_id)`. +- `identity_keys` is the exception among identity-owned tables: it carries + a nullable `wallet_id BLOB` column (a denormalised copy of + `identities.wallet_id`, not part of its `(identity_id, key_id)` PK) and + two `ON DELETE CASCADE` FKs — a simple `wallet_id → wallets(wallet_id)` + and a compound `(wallet_id, identity_id) → identities(wallet_id, + identity_id)` — so a delete on either parent cascades to it. Both FKs go + dormant together on a NULL-scoped (unowned-identity) row; see + `identity_keys` under Tables. - The five typed `meta_*` tables carry **no FK** (writes may precede the parent); cleanup is an `AFTER DELETE` soft cascade. A wallet delete fires a wallet-rooted trigger that brooms the wallet-scoped `meta_*` tables by `wallet_id`, and the @@ -695,15 +771,58 @@ having to grep this repo. | Trigger | Fires | Action | |---|---|---| -| `setnull_core_utxos_on_tx_delete` | AFTER DELETE ON `core_transactions` | NULL `core_utxos.spent_in_txid` for the deleted tx | -| `cascade_meta_on_wallet_delete` | AFTER DELETE ON `wallet_metadata` | delete `meta_wallet`, `meta_contact`, `meta_platform_address` rows by `wallet_id` | +| `cascade_meta_on_wallet_delete` | AFTER DELETE ON `wallets` | delete `meta_wallet`, `meta_contact`, `meta_platform_address` rows by `wallet_id` | +| `cascade_meta_data_versions_on_wallet_delete` | AFTER DELETE ON `wallets` | delete `meta_data_versions` rows by `wallet_id` | | `cascade_meta_on_identity_delete` | AFTER DELETE ON `identities` | delete `meta_identity`, `meta_token` rows by `identity_id` | | `cascade_meta_token_on_token_balance_delete` | AFTER DELETE ON `token_balances` | delete matching `meta_token` rows (direct balance delete) | | `cascade_meta_contact_on_contact_delete` | AFTER DELETE ON `contacts` | delete matching `meta_contact` rows (any state; direct contact delete) | | `cascade_meta_platform_address_on_address_delete` | AFTER DELETE ON `platform_addresses` | delete matching `meta_platform_address` rows (direct address delete) | +| `identity_keys_null_scope_requires_unowned_identity` | BEFORE INSERT ON `identity_keys` WHEN `NEW.wallet_id IS NULL` | abort unless the named identity exists and is itself unowned — the sole guard against a NULL-scoped key naming a wallet-owned (or, since V016, missing) identity, since SQLite's `MATCH SIMPLE` leaves both `identity_keys` FKs dormant whenever a child-key column is NULL | +| `identity_keys_null_scope_requires_unowned_identity_on_update` | BEFORE UPDATE ON `identity_keys` WHEN `NEW.wallet_id IS NULL` | the same guard on the UPDATE path — necessary because the writer's upsert resolves an existing `(identity_id, key_id)` to `DO UPDATE`, which never fires a `BEFORE INSERT` trigger | ## Migrations +Versions **V001-V007 are owned by merged `v4.2-dev` history and are +byte-identical to what it shipped**: refinery keys `refinery_schema_history` by +version and validates an applied migration's checksum against the embedded +migration of the *same* version, so pointing one of those versions at different +DDL would stop every database that applied the original from opening. New work +appends after the highest version here, never onto a published one. + +Two guards enforce it: `merged_migration_versions_keep_their_shipped_names` +pins the version-to-name bindings, and `tc_b_031` opens a database created by +`v4.2-dev`'s own binary (`tests/fixtures/v4_2_dev_migrated.db`) and migrates it +the whole way forward. + +`V008` is where the rehydration reshape lives. Everything it does was +originally written into V001 in place, which is not available once V001 is +published, so it appends instead: it renames `wallet_metadata` to `wallets`, +rebuilds `account_registrations` and `identity_keys`, drops the two superseded +address tables, and stamps the header `application_id`. Because a `v4.2-dev` +database predates that stamp, `open` accepts an unstamped file whose refinery +history names migrations this binary embeds, and refuses anything else. + +Every `CHECK (col IN (...))` domain inside a migration is a frozen literal, +never interpolated from a live `*_LABELS` const: an added enum variant must not +rewrite an applied migration's SQL. Widening a domain means appending a +table-rebuild migration, as V004 does. + | Version | File | Description | |---|---|---| -| V001 | `V001__initial.rs` | Full schema: all 23 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) | +| V001 | `V001__initial.rs` | Full base schema: all 23 tables (including the six `meta_*` per-object metadata tables), every index, and the original trigger set. Byte-identical to `v4.2-dev`. | +| V002 | `V002__address_height_pin.rs` | Adds `platform_addresses.as_of_height` (the Platform-block-height pin reconciling proof-attested balances against the delta stream; `DEFAULT 0` = unknown provenance for pre-existing rows). Additive column, no new table. | +| V003 | `V003__invitations.rs` | Adds `invitations` for DIP-13 DashPay invitation lifecycle records, keyed by wallet and outpoint. | +| V004 | `V004__asset_lock_recovered_status.rs` | Widens `asset_locks.status` to add `recovered_from_chain` (the restore-scan reconstruction status: Core finality proven via a chain-locked record, Platform-side consumption unknown). SQLite can't alter a CHECK in place, so the table is rebuilt — widened twin created, rows copied (dropping any orphaned by a wallet deleted while FK enforcement happened to be off), old table dropped and the twin renamed. | +| V005 | `V005__dpns_name_states.rs` | Adds `dpns_name_states` for the DPNS username marketplace: one row per tracked domain document, carrying sale state (`owned \| sold \| transferred`), listed `price`, and `counterparty_id` (buyer/recipient, NULL for `owned` rows). | +| V006 | `V006__tracked_masternodes.rs` | Adds `tracked_masternodes`, keyed by `(network, pro_tx_hash)` and deliberately NOT wallet-scoped — a tracked masternode belongs to no wallet and survives deleting any one of them. `snapshot_json` caches public DML/Platform-identity data only; any key material a user attaches to a tracked node lives in host secure storage, never here. | +| V007 | `V007__utxo_sweep_winner_height.rs` | Adds sweep winner height, chainlock height, and the placeholder index. | +| V008 | `V008__rehydration_base_schema.rs` | The rehydration reshape, appended rather than edited into V001: stamps `application_id`; renames `wallet_metadata` to `wallets` (which rewrites every dependent FK clause and the cascade trigger); rebuilds `account_registrations` with the `key_class` / DashPay identity-pair discriminators, the widened `account_type` domain, and the legacy `standard` label rewritten to `standard_bip44`; drops `account_address_pools` and `core_derived_addresses`; adds `core_sync_state.last_applied_chain_lock`; renames `identities.wallet_index` to `identity_index`; and rebuilds `identity_keys` with its own `wallet_id` scope, a compound FK, and the NULL-scope trigger pair. Both rebuilds sweep orphaned rows first. | +| V009 | `V009__unified.rs` | Adds `core_address_pool` (per-index address-pool rows replacing `core_utxos` script-derivation for the address-reuse guard), `meta_data_versions` (per-`(wallet_id, domain)` cache-invalidation `seq`), and `meta_store_generation` (single-row store-generation token). Additive only. | +| V010 | `V010__pool_public_key.rs` | Adds nullable `public_key` and `key_type` columns to `core_address_pool`, preserving typed pre-derived public keys that a watch-only account cannot regenerate (closes #4113). | +| V011 | `V011__pool_reserved_at.rs` | Adds nullable `core_address_pool.reserved_at` to persist `AddressState::Reserved` timestamps while available and used rows remain unreserved. | +| V012 | `V012__drop_core_utxo_metadata.rs` | Removes unused `core_utxos.account_index` and temporarily drops the cleanup trigger for the transaction-table rebuild; owning-account identity is resolved from `core_address_pool` during reads. | +| V013 | `V013__shielded_viewing_keys.rs` | Adds `shielded_viewing_keys` to persist Orchard full viewing keys by wallet and shielded account. | +| V014 | `V014__single_source_core_confirmation_height.rs` | Rebuilds `core_transactions` with nullable `record_blob` for height-only rows, preserves existing transaction metadata and blobs, and drops `core_utxos.height` so UTXO confirmation height has one authority (#4178). Preserves sweep holds with `is_sweep_placeholder`, rebuilds their partial index, and restores the `spent_in_txid` cleanup trigger. Both source tables are swept of rows orphaned by a wallet deleted while FK enforcement happened to be off, since copying one into the FK-declared twin would abort the whole migration. | +| V015 | `V015__purge_legacy_empty_script_spent_utxos.rs` | Deletes legacy `core_utxos` rows matching `spent = 1 AND length(script) = 0 AND is_sweep_placeholder = 0`, left by a producer that fabricated an empty script for a spend of an output the wallet never recorded. One such row rejects the load of the whole file, since `load_used_addresses` decodes every stored script with no load-policy escape hatch. Balance-neutral: the balance readers select `spent = 0` only. | +| V016 | `V016__identity_keys_null_scope_requires_existing_identity.rs` | Recreates the `identity_keys` null-scope trigger pair (see Triggers above) to also reject a NULL-scoped key naming an identity that does not exist at all, closing the gap where V008's guard caught only the wallet-owned case. | +| V017 | `V017__identity_scan_state.rs` | Adds `identity_scan_states` (one row per wallet: the last gap-limit identity-scan verdict — `complete`, `probed_from`/`probed_through`, `unlocated_gap`) and `identity_scan_failed_indices` (indices probed without an answer, cascading from the verdict row via `wallet_id`). Purely additive; an upgraded database reads back "no verdict recorded" for every wallet until the next scan (dashpay/platform#4365). | diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md index 7f983aa071c..3f5c23c5cc1 100644 --- a/packages/rs-platform-wallet-storage/SECRETS.md +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -9,6 +9,16 @@ move funds. Keeping signing material out of that file by construction is what makes the rest of the crate safe to operate casually: you can back up the `.db` without backing up your keys. +Copying it freely does carry one caveat that is about *deleted* data rather +than keys. SQLite frees pages without clearing them, and `Backup` copies pages +including the freelist, so a removed wallet's rows could otherwise ride along +in every later snapshot. The persister therefore runs with +`PRAGMA secure_delete = FAST` throughout and raises it to `ON` for the +`delete_wallet` cascade, where whole pages are released and only `ON` clears +them. What that does not do — and cannot — is scrub a backup taken before the +deletion. Those snapshots still hold the wallet, by design; delete them +yourself if the point of the deletion was to make the data unrecoverable. + So secrets get their own home, their own crypto, and their own typed, secret-free error surface — separate from the persister entirely. @@ -30,6 +40,17 @@ The rest of this document is the technical detail behind that boundary: the `secrets` backends, the `SecretStore` API, the error surface, and the threat model. +### Exception: the KV metadata API stores caller-supplied plaintext + +The boundary above is about the persister's own domain state. The +separate `KvStore` API (`kv` feature) is a deliberate, explicit exception: +it stores **arbitrary caller-supplied `Vec` values as PLAINTEXT** in +`meta_*` BLOB columns of the same `.db` (and therefore in every backup). +There is no encryption and no runtime content guard — the safety is +**caller-policed**. Callers MUST NOT put key or signing material through +`KvStore`; that is what `SecretStore` is for. The `KvStore` / +`KvStore::put` rustdoc carries the same `# Security` warning. + ## The `secrets` submodule `platform_wallet_storage::secrets` is part of the crate's default @@ -49,10 +70,27 @@ arm, so `WrongPassphrase` vs `Corruption` vs `AlreadyLocked` stay distinct. ```rust use platform_wallet_storage::secrets::{SecretBytes, SecretStore, SecretString, WalletId}; -let store = SecretStore::file("/var/lib/wallet/secrets.pwsvault", SecretString::new("pw"))?; +let store = SecretStore::file( + "/var/lib/wallet/secrets.pwsvault", + SecretString::new("correct-horse-battery-staple"), +)?; let wallet = WalletId::from(wallet_id); + +// Tier-1 only (unprotected by an object password). `set`/`get` are +// `..,None` wrappers over `set_secret`/`get_secret`. store.set(&wallet, "mnemonic", &SecretBytes::from_slice(b"abandon ability ..."))?; let plaintext: Option = store.get(&wallet, "mnemonic")?; // never a bare Vec + +// Tier-2: protect a critical object under an extra OBJECT PASSWORD that +// the backend never sees. Reading it back REQUIRES the password. +let pw = SecretString::new("a strong object password"); +store.set_secret(&wallet, "seed", &SecretBytes::from_slice(b""), Some(&pw))?; +let seed = store.get_secret(&wallet, "seed", Some(&pw))?; // Some(secret) +// Reading a protected object WITHOUT the password fails closed: +assert!(store.get_secret(&wallet, "seed", None).is_err()); // NeedsPassword + +// Add / change / remove an object password in one atomic same-slot flow: +store.reprotect(&wallet, "seed", Some(&pw), None)?; // remove → now unprotected store.delete(&wallet, "mnemonic")?; // idempotent ``` @@ -61,6 +99,208 @@ filename); the parent directory is materialized on the first write. Use `SecretStore::os()` for the platform OS keyring arm instead of `SecretStore::file(..)`. +See **Two-tier secret protection** below for the model, the envelope +format, which tier defeats which adversary, and the strict fail-closed +read that is the heart of the opt-in scheme. + +### Two-tier secret protection + +Secret protection comes in two layers. Tier-1 is always on (it is just +"which backend you opened"); Tier-2 is opt-in, per critical object, and +backend-independent. + +| Tier | Provided by | Defeats | Mechanism | +|---|---|---|---| +| **1 — backend baseline** | the *backend* | another local user, a lost laptop, the vault at rest | OS keychain ACLs **or** Argon2id + XChaCha20-Poly1305 vault under a **real** passphrase | +| **2 — per-object password** | the *library*, above `SecretStore`, over **both** arms | **backend compromise** — the keychain scraped, or the vault stolen *and* its passphrase cracked | the object's bytes are Argon2id + XChaCha20-Poly1305 **enveloped under a per-object password BEFORE they reach the backend** | + +**Why Tier-2 is more than key granularity.** Its value is not a sub-key — +it is (a) an **independent human password the backend never sees** and (b) +**envelope-before-backend ordering**, so for a protected object the backend +only ever stores ciphertext. That is the first and only control that keeps +a chosen critical object confidential across a *full* backend compromise +(the A2/A3/A6 gap Tier-1 leaves open). + +Tier-2 has two guarantees of different strength: + +- **Confidentiality** (an attacker cannot *read* a protected secret) is + **unconditional** — the object password never enters any backend, so a + full backend dump yields only ciphertext + a per-object salt to + offline-Argon2id-crack against the password's entropy. +- **Integrity / anti-downgrade** is delivered by the **strict fail-closed + read** below and is **conditional on the caller's trusted model staying + intact** (see the documented residual). + +#### The envelope (wire format) + +Every value written through `set_secret`/`set` is wrapped in a +self-describing, authenticated envelope before it reaches the backend. The +backend (file vault or OS keychain) stores only these opaque bytes. + +The canonical wire format is **bincode-encoded** under a single +`WIRE_CONFIG = standard().with_big_endian().with_no_limit()` against +two `pub(crate)` types whose shapes are the source of truth — see +[`src/secrets/wire/envelope.rs`](src/secrets/wire/envelope.rs) and +[`src/secrets/wire/mod.rs`](src/secrets/wire/mod.rs): + +```rust +struct Envelope { version: u32, payload: Payload } +enum Payload { + Unprotected(Vec), // scheme 0 + Password { // scheme 1 + kdf: KdfParamsEncoded, // id u8 ‖ m_kib u32 ‖ t u32 ‖ p u32 + salt: [u8; 32], nonce: [u8; 24], + ciphertext: Vec, // includes the 16-byte Poly1305 tag + }, +} +``` + +`ENVELOPE_VERSION = 1` is bumped only on a breaking layout change, +independent of the vault `FORMAT_VERSION`. Decoding goes through a +budget-limited `DECODE_CONFIG = WIRE_CONFIG.with_limit::()` so a +hostile blob declaring a multi-GiB length prefix is rejected before +allocation (security-positive deviation from the no-limit encoder +config). Trailing bytes after a valid decode are also refused — +`consumed == blob.len()` is a fail-closed invariant. + +- **AAD (scheme 1)** is bincode-encoded from `Tier2Aad` + ([`src/secrets/wire/aad.rs`](src/secrets/wire/aad.rs)), which binds + `domain (PWSEV-TIER2-AAD-v2) ‖ envelope_version ‖ scheme_discriminant + ‖ kdf ‖ salt ‖ wallet_id ‖ label`. The vault's own per-entry AAD goes + through `EntryAad` (`domain (PWSV-ENTRY-AAD-v2) ‖ format_version ‖ + wallet_id ‖ label`) and the vault verify-token AAD through `VerifyAad` + (`domain (PWSV-VERIFY-AAD-v2) ‖ format_version ‖ salt ‖ kdf`). All + three domain tags are pair-wise byte-disjoint by construction. A + protected blob relocated to another slot — or any in-place header + edit — fails the tag (relocation/header-tamper resistance). On the + file arm this AAD is *in addition* to the vault's own per-entry AAD + + tag; on the OS arm it is the only authentication layer. +- **KDF ceiling before derivation (anti-DoS).** The KDF params live in + the (attacker-controllable) header, so on a read the Argon2 ceiling + is enforced **before** any derivation/allocation — both the wider + `enforce_bounds` (algorithm id + floors/ceilings) AND a tighter + per-read gate that refuses any `m_kib > ARGON2_READ_MAX_M_KIB` OR + `t > ARGON2_READ_MAX_T`. A forged header cannot inflate memory or CPU + beyond that ceiling. + + Those two constants are **wire-format, not tunables**: the read gate is + deliberately decoupled from `default_target()`, which is an ordinary + write-side tunable, so lowering the shipped default can never orphan an + already-enrolled secret. A `const` assertion keeps the write target at or + below the ceiling, so raising the default past it breaks the build rather + than the users. The ceiling may only ever be RAISED — a header this build + refuses is unrecoverable. +- **No vault format bump.** The envelope lives *inside* the entry + bytes, identical over File and Os, so there is no vault-parser or + migration change. +- **Size cap.** The plaintext is capped at `MAX_PLAINTEXT_LEN` + (`MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD`), uniformly for both + schemes, so the enveloped bytes always fit the backend's own + `MAX_SECRET_LEN` cap and the user-visible limit is stable regardless + of scheme. Oversize → `SecretTooLarge { found, max }` with + `max = MAX_PLAINTEXT_LEN` (re-exported as `secrets::MAX_PLAINTEXT_LEN`). +- **Unknown envelope version** → `UnsupportedEnvelopeVersion` — fail + closed **regardless of the password**: an envelope tagged for a + future layout can be neither safely unwrapped nor treated as + unprotected. +- **Unparseable bytes / unknown scheme tag / trailing garbage** → + `Corruption`. There is no magic-byte peek — every blob runs through + the bincode decoder, and anything that does not round-trip cleanly + with `consumed == blob.len()` fails closed. + +#### The strict, fail-closed read + +The defining risk of any opt-in "some objects are extra-protected" scheme +is **strip / downgrade**: an attacker who can WRITE the backend replaces a +protected blob with a fresh, internally-valid *unprotected* (scheme-0) blob +carrying a chosen seed/xpriv. There is nothing in that blob alone to prove +an envelope was *expected*, so inferring protection from the stored bytes +would silently return the attacker's secret — funds redirection, password +prompt bypassed. + +The fix: **the "expected-protected" bit lives in the CALLER's trusted +model, surfaced solely by whether a password is supplied to `get_secret` — +NEVER inferred from the blob.** The library does not guess and does not +persist the expectation. A supplied password *is* the assertion "this +object must be protected": + +| `password` arg | stored blob | result | +|---|---|---| +| `Some(pw)` | valid scheme-1 | the secret, or `WrongPassword` on tag fail | +| **`Some(pw)`** | **valid scheme-0 envelope** | **`ExpectedProtectedButUnsealed` — FAIL CLOSED** | +| `Some(pw)` | scheme-1 but truncated/corrupt | `Corruption` | +| `Some/None` | unknown envelope version | `UnsupportedEnvelopeVersion` | +| `Some/None` | unparseable / non-envelope bytes / trailing garbage | `Corruption` | +| `None` | valid scheme-1 | `NeedsPassword` (never ciphertext) | +| `None` | valid scheme-0 envelope | the secret | +| any | absent entry | `Ok(None)` (deletion = DoS, never injection) | + +The load-bearing row is **`Some(pw)` + scheme-0 envelope ⇒ +`ExpectedProtectedButUnsealed`**: with a password in hand, an +unprotected envelope can only mean a strip, so it is refused and **no +bytes are returned**. A consumer bug alone — over- or under-supplying +a password — fails closed in *every* direction. + +**Arm asymmetry.** On the file arm the stored bytes are themselves sealed +under the vault key, so producing a *readable* stripped blob at a slot +requires the vault key; a cold/backup-swap actor can only corrupt +(→ DoS), not inject-to-readable. On the OS-keychain arm the stored item is +the bare envelope with no second seal, so the strip defence there leans +entirely on the `Some(pw)` strict rule plus the consumer's metadata +integrity — this is where the residual bites hardest. + +**Documented residual (out of the library's reach).** If an attacker ALSO +rewrites the consumer's trusted DB so the consumer calls `get_secret(X, +None)` for a stripped object, the `(scheme-0, None)` quadrant returns the +attacker's bytes. The library only ever sees the blob and the caller's +`Some/None`; the "should be protected" fact lives entirely in the +consumer's metadata store. **Anti-downgrade strength therefore equals the +tamper-resistance of the consumer's protection-status record** — store it +as integrity-protected, security-critical state (it is one more field +alongside the addresses/policy the wallet DB must already protect). + +**Value rollback is NOT defended.** Restoring an *older valid* scheme-1 +envelope under the *current* password decrypts cleanly. The strict read +closes the strip/downgrade injection, not value rollback; if +backup-swap/restore-old is in scope, anchor a monotonic version in +integrity-protected consumer metadata. Do not mistake the strict read for +rollback protection. + +#### Add / change / remove an object password + +`reprotect(service, label, current, new)` does it in one same-slot +unwrap→rewrap→overwrite: read under the `current` expectation (so a strip +is caught before any rewrite), then write under `new` — `None`→`Some` adds, +`Some`→`Some` changes, `Some`→`None` removes. An absent object returns +`Err(SecretStoreError::NoEntry)` — `reprotect` is operational, so absence +means the caller's protection-status record disagrees with the backend and +must not be silently dropped. The rewrite is a same-slot overwrite — atomic on the file arm, +and on the OS arm inheriting the backend's single-item-replace contract — +so a crash between the read and the commit leaves the prior value intact +and readable under `current`. **After a successful call the consumer MUST +update its own protection-status record** (the protection expectation lives +there). There is **no password recovery** — losing an object password +bricks that object (an availability trade-off the UX must state plainly). + +#### Entropy policy is the consumer's + +The library enforces an 8-byte post-trim `MIN_PASSPHRASE_LEN` floor for both +the vault passphrase and the Tier-2 object password. It ships **no** +password-strength estimator: real entropy policy (zxcvbn-style strength, +dictionary checks, UX feedback) is locale- and threat-specific and is the +**consumer's responsibility**. For a protected object the password's +entropy is the *whole* guarantee against an offline Argon2id attacker who +already holds the backend — choose it accordingly. + +#### Greenfield only — no legacy tolerance + +The envelope is the only on-disk Tier-2 format this build understands. +A decrypted entry that does not bincode-decode to a valid `Envelope` +under `WIRE_CONFIG` (including trailing-byte extension probes) surfaces +as `Corruption` on every read — there is no magic-byte peek and no +magic-less raw legacy path. The shipped wire layer is the source of +truth; older non-enveloped stored values are out of scope. + ### Internal SPI Below `SecretStore`, `EncryptedFileStore` and `default_credential_store` @@ -86,11 +326,48 @@ operation (defence in depth — credentials are long-lived). never crosses the public boundary. Internally, the upstream SPI returns plaintext as `Vec` from `CredentialApi::get_secret`; that result is wrapped into `SecretBytes::new(...)` **immediately**, with no named -intermediate `Vec` binding. `SecretBytes::new` takes the -`Vec` by value and `std::mem::take`s it into a `Zeroizing>` — -no copy of the bare buffer ever survives past the constructor -expression, so the bare-`Vec` exposure window is zero statements. The -wrapper is also best-effort `mlock`ed and `Debug` is redacted. +intermediate `Vec` binding. + +`SecretBytes::new` takes the `Vec` by value, **copies** it into +guarded memory, then zeroizes the source before it drops. The copy is +unavoidable and load-bearing: guarded memory comes from a dedicated +allocator, so a `Vec`'s own allocation can never *become* the protected +buffer — wiping the original is the only thing that keeps an +unprotected duplicate off the general-purpose heap. `SecretString::new` +does the same for a moved-in `String`; `From<&str>` and the +`serde`-gated `visit_str` bypass the intermediate allocation entirely. +`Debug` is redacted on both. + +`SecretString` is additionally **editable in place**, through the single +`replace_range(range, replacement)` primitive (insertion is an empty +range, deletion an empty replacement, wholesale replacement `..`) — it +backs live text-input widgets downstream without them keeping a +duplicate guarded buffer of their own. No plaintext leaves the wrapper +through it: an edit that outgrows the buffer allocates a fresh guarded +one, copies through a safe slice, and lets the outgrown one wipe itself +on drop; a shrinking edit wipes the bytes it vacates. An invalid range +panics (matching `String::replace_range`) with a message naming **only +indices** — never content, since `str`'s own slicing panic would print +the surrounding plaintext (CWE-209/CWE-532). The buffer is deliberately +**uncapped** here: a value type cannot report a refusal, so enforcement +stays at the UI that accepts the input and at the vault write, which +applies `MAX_PLAINTEXT_LEN`. + +**Every secret owns its own guarded pages.** The buffer comes from +`memsec`'s hardened allocator (`src/secrets/guarded.rs`, the crate's +only `unsafe`): page-aligned, fenced by inaccessible `PROT_NONE` guard +pages, canary-checked, `mlock`ed, and excluded from core dumps +(`MADV_DONTDUMP` on Linux). Because the data pages belong to one buffer +outright, **no two live secrets ever share a page**, so freeing one can +never unlock memory another still holds — the failure mode that makes +page-granular locking hazardous over ordinary allocations. The wipe +covers the buffer's full capacity, not just the live length. + +The `mlock` remains **best-effort / fail-open**: if the kernel refuses +the lock the secret is still allocated, guard-paged and wiped, merely +swappable. That refusal is logged at `warn` (with no address, length or +content), so a degraded lock is observable rather than silent. An +opt-in fail-closed strict mode is not implemented. `SecretStore::set` takes `&SecretBytes`, exposing the wrapped bytes to the SPI's `set_secret(&[u8])` only at the last moment; no long-lived @@ -118,6 +395,50 @@ unwrapped copy is allocated. One file, one passphrase, one lock — a multi-wallet store cannot lock its other wallets out by construction. Errors surface as the typed `SecretStoreError` through `SecretStore`. + On Unix the check covers EVERY ancestor of the vault's parent up to `/`, + walked twice — over the lexical path and over its canonical target — so a + symlink cannot hide an unsafe ancestor behind a safe-looking one. An + ancestor is refused at `open` with `SecretStoreError::InsecureParentDir` + when it is group/other writable (`mode & 0o022`) WITHOUT the sticky bit, + or when it is owned by neither the effective user nor a root identity: + directory write access governs rename/replace of the vault, and an + untrusted owner can grant itself that access at will (the A1 guarantee + depends on both). A sticky writable directory such as `/tmp` (`0o1777`) + is accepted — the sticky bit is what stops one user replacing another's + entries. A read-only group-accessible ancestor (`0o750`) is accepted too + — it only leaks filenames, never the 0600-protected vault contents. The + walk is Unix-only; Windows ACLs are not inspected (issue #3754). + Each secret is capped at `MAX_SECRET_LEN` (8176 B) at the write + boundary — still ~30× any mnemonic/seed/xpriv — so a single oversized + entry cannot inflate the shared document past the read-side 128 MiB + ceiling and brick every wallet on the next open. The value is set by + locked memory, not by the document: secrets live in `mlock`ed pages, + and 8176 fits inside a single guarded page on every supported host. + The full budget — which path peaks, at what, against a 256 KiB + `RLIMIT_MEMLOCK` — is documented at the constant and measured by + `store::tests::file_reprotect_peak_matches_the_documented_budget`. (Through + `SecretStore::set_secret`/`set` the user-facing plaintext cap is the + slightly lower `MAX_PLAINTEXT_LEN`, leaving room for the envelope + overhead; see **Two-tier secret protection**.) + **Short passphrases are rejected.** `open` (and `rekey`) require at least + 8 bytes after trimming and return `SecretStoreError::BlankPassphrase` for a + shorter input. A + deliberate keyless vault uses the explicit + `EncryptedFileStore::open_unprotected(path)` / + `SecretStore::file_unprotected(path)` door instead (use it only where the + stored secrets carry their own Tier-2 object password, or as a staging + step before `rekey` to a real passphrase — the empty→real migration). + **Over-long passphrases are rejected too.** `open`/`rekey` and both + sides of the Tier-2 object-password path refuse anything past + `MAX_PASSPHRASE_LEN` (4080 B, one guarded page) with + `SecretStoreError::PassphraseTooLong`. This is a memory bound at the store + boundaries, not a policy one: a passphrase stays resident in `mlock`ed + pages for its store's whole lifetime, and three are live at once during a + `reprotect`, so an unbounded one would break the locked-memory budget above. + The `serde`-gated `Deserialize` impl applies the same ceiling, since config + is the one construction path whose size this crate does not control. A + caller-driven `SecretString::replace_range` applies no ceiling and can grow + a value past `MAX_PASSPHRASE_LEN` before it reaches those boundaries. - **OS keyring (`SecretStore::os` / `default_credential_store`)** — returns an `Arc` over the platform's default credential store. The backend on Linux/FreeBSD is @@ -135,40 +456,82 @@ unwrapped copy is allocated. with `NoDefaultStore`. Callers that need durable storage on a headless host should pin `SecretStore::file(...)` (encrypted-file vault) instead of relying on the OS keyring. -- **Tests** — integration tests construct a tempdir-backed - `EncryptedFileStore` directly via - `EncryptedFileStore::open(tempfile::tempdir()?.path().join("vault.pwsvault"), SecretString::new("..."))`, - or use the public `SecretStore::file(path, passphrase)` constructor. - No special feature flag is required; both are available under the default - `secrets` feature. + + **Enumerable metadata (OS arm).** Each entry is keyed by + `service = SERVICE_PREFIX + hex(wallet_id)` and `user = label`, stored + as **plaintext, enumerable** keyring metadata: same-user list-only + tooling can see which wallet ids exist and which slot kinds (labels) + each has, without unlocking any secret. This is dominated by the + already-accepted same-user (A2/A3) residual. The `keyring-core` 1.0.0 + `build` modifiers are vendor-specific creation hints, not a replacement + for the `(service, user)` identity, so there is no portable knob to + redact the pair; operators who need metadata hiding should use the file + vault, whose `(wallet_id, label)` map lives only inside the sealed + vault. Prefer non-descriptive labels on the OS arm regardless. + +#### Tests + +Ordinary integration tests use `EncryptedFileStore::open` or +`SecretStore::file` and therefore exercise the production Argon2id target. +Downstream suites that would otherwise pay that cost throughout an end-to-end +flow may enable the dev-only `test-util` feature and use +`EncryptedFileStore::open_mock` or `SecretStore::file_mock`. For a fresh vault, +those constructors select the floor Argon2id parameters; an existing vault +retains the parameters recorded in its header. Per-object wrapping also uses +the floor. `KdfParams::floor_target` is the single choke point for selecting +these weak-but-legal parameters. Accidental production use is blocked twice: +the constructors are compiled only for tests or with `test-util`, and +`KdfParams::floor_target` panics outside debug builds and this crate's own test +harness. Backend selection is an explicit operator decision; there is no automatic fallback between backends. ### Error surface -`SecretStore` returns the typed `SecretStoreError`. For the file arm this -is **lossless**: `WrongPassphrase`, `Corruption`, `AlreadyLocked`, -`KdfFailure`, `VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, -`VaultTooLarge`, and `InvalidLabel` are distinct typed variants -(`VaultTooLarge` surfaces when the on-disk vault exceeds the 128 MiB -ceiling). For the OS arm, -`keyring_core::Error` projects best-effort into -`SecretStoreError::OsKeyring { kind: OsKeyringErrorKind }`, a payload-free -discriminant — keyring variants carrying raw bytes (`BadEncoding`, -`BadDataFormat`) are collapsed so their bytes never enter the error -(CWE-209/CWE-532). +`SecretStore` returns the typed `SecretStoreError`. The main caller decisions +are whether to retry credentials (`WrongPassphrase`, `WrongPassword`, +`NeedsPassword`), reject a protection downgrade +(`ExpectedProtectedButUnsealed`), repair corrupt or unsupported data, fix an +unsafe or over-budget host setup (including `HostPageSizeExceedsBudget`), +handle `NoEntry`, or surface an `Io` / `OsKeyring` backend failure. See +[`src/secrets/error.rs`](./src/secrets/error.rs) for the authoritative variants +and their backend mappings. + +**`WrongPassword` on the OS arm is ambiguous.** A Tier-2 envelope AEAD tag +failure surfaces as `WrongPassword`, but on the OS-keyring arm the stored +item is the bare envelope with no second authentication layer, so a tag +failure can mean EITHER a wrong object password OR a corrupted keychain +item — one AEAD tag cannot disambiguate the two. Treat `WrongPassword` on +the OS arm as "wrong password or corrupted item." On the file arm it is +unambiguous: the vault's own per-entry tag has already authenticated the +stored bytes before the envelope is parsed. + +**`WrongPassphrase` on the file arm is ambiguous at the vault header.** The +Tier-1 header's verification token has no integrity check independent of the +passphrase-derived key. Its AEAD tag therefore cannot distinguish an incorrect +vault passphrase from corruption of the header salt, KDF parameters, nonce, or +ciphertext. Treat file-arm `WrongPassphrase` as "wrong passphrase or corrupted +header." This ambiguity is limited to the Tier-1 header; after the header is +verified, the vault's per-entry authentication keeps Tier-2 `WrongPassword` +unambiguous on the file arm as described above. The internal SPI projection `From for keyring_core::Error` keeps the `WrongPassphrase` / `AlreadyLocked` variants recoverable: they ride in `NoStorageAccess` with the typed `SecretStoreError` boxed as the source, so an SPI-only consumer can recover them via `err.source().and_then(|s| s.downcast_ref::())`. -The `BadStoreFormat` group (`Corruption`, `KdfFailure`, -`VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, -`VaultTooLarge`, `Decrypt`, `OsKeyring`) has no box slot and carries only a -secret-free string; those remain fully typed on the `SecretStore` path -(so `VaultTooLarge` is not losslessly recoverable through the SPI downcast). +The `BadStoreFormat` group (`Corruption`, `KdfFailure`, `EntropyUnavailable`, +`VersionUnsupported`, `UnsupportedEnvelopeVersion`, `MalformedVault`, +`InsecurePermissions`, `InsecureParentDir`, `SecretTooLarge`, +`PassphraseTooLong`, `VaultTooLarge`, `Decrypt`, `Encrypt`, `OsKeyring`) has +no box slot and carries only a secret-free string; those remain fully typed +on the `SecretStore` path (so e.g. +`VaultTooLarge` / `SecretTooLarge` are not losslessly recoverable through +the SPI downcast). The remaining two variants project outside both groups: +`InvalidLabel` → `KeyringError::Invalid("user", _)`, and `NoEntry` and `Io` +pass through as `KeyringError::NoEntry` and `KeyringError::PlatformFailure` +respectively (`Io`'s inner OS error boxed as the source). `keyring_core::Error` is safe to `Display` (`{ }`-format), but `{:?}`-format embeds `BadEncoding(Vec)` / `BadDataFormat(Vec, _)` @@ -208,8 +571,10 @@ secret-free. `default_credential_store` from the crate root; the body never exercises a backend, so the proof is that it compiles. The negative direction — `--no-default-features --features sqlite,cli` must build - the persister without the `secrets` module — is enforced by the - feature gate plus the CI off-state build, not by a test file. + the persister without the `secrets` module — rests on the feature gate + alone. No test file and **no CI job** cover it: that invocation is a + local/manual check, so a regression in the off-state build reaches + `main` unnoticed. - **`tests/sqlite_persist_roundtrip.rs::tc082_no_box_dyn_error_in_src`**: all public method signatures use concrete error types (`WalletStorageError`, `PersistenceError`) — never @@ -219,9 +584,63 @@ The CI advisory check runs `rustsec/audit-check` over `Cargo.lock`; because `secrets` is in the default feature set, the pinned `argon2` / `chacha20poly1305` / `zeroize` / `subtle` / `getrandom` (the `OsRng` source for the salt + per-entry nonces, specified as the -exact pin `getrandom = "=0.2.17"`) / `region` / `keyring-core` / +exact pin `getrandom = "=0.2.17"`) / `memsec` / `keyring-core` / per-platform store crate versions are unconditionally in the lockfile -and therefore unconditionally in audit scope. +and therefore unconditionally in audit scope. `memsec` (exact pin +`=0.7.0`) deserves the closest reading of the set: it performs every +page lock, every guard-page `mprotect`, and backs the crate's only +`unsafe`, all inside `src/secrets/guarded.rs`. `region` (exact pin +`=3.0.2`) is a normal dependency enabled by the `secrets` feature and +**is in the production dependency graph**: `verify_host_page_size` calls +`region::page::size()` on every store construction, not only from the +page-isolation tests. Its audit surface is that one query. + +## Integration constraints + +Guarded allocation is not free, and it constrains what a consuming +binary may do. Three consequences, none of them visible from the public +API: + +- **Every non-empty secret costs at least one locked page** — 4 KiB on + x86-64/aarch64 Linux, 16 KiB on Apple Silicon and iOS — plus guard + pages of address space, however small it is: a 32-byte AEAD key + included. That is the price of the no-shared-page guarantee. + Empty secrets are the one case optimised away: `SecretString::empty()` + and an empty `SecretBytes` hold no allocation at all. Budget one page + per live secret and check `RLIMIT_MEMLOCK` against it; if the limit is + too low the locks fail open (see "Memory hygiene at the seam") and a + `warn` is logged per affected allocation. +- **The budget assumes 16 KiB pages and refuses a host with larger + ones.** `memsec` rounds each allocation to the page size the kernel + reports at run time, which a compile-time budget cannot see, so + `ASSUMED_PAGE_SIZE` (16 KiB) bounds the largest supported host rather + than describing the commonest. Every mainstream target passes: 4 KiB + x86-64 and aarch64 Linux, 16 KiB Apple Silicon and iOS. A larger-paged + host — a 64 KiB-page aarch64 RHEL/SLES build — is refused at store + construction with `SecretStoreError::HostPageSizeExceedsBudget` rather + than allowed to overrun its budget and fail open. On a 4 KiB host the + accounting is a deliberate over-estimate: the crate charges four times + what the kernel really locks. +- **Nothing calls `getrlimit`.** `MEMLOCK_BUDGET` (256 KiB) is an + arithmetic ceiling the constants are asserted against at compile time, + not a limit checked against the host at run time. It is set far below + what hosts actually grant — systemd has defaulted + `DefaultLimitMEMLOCK` to 8 MiB for years, and a default Docker + container inherits it — but a consumer running under a deliberately + restrictive limit gets the fail-open path and a `warn`, not a refusal. + Check the limit at your own startup if that matters to you. +- **No custom global allocator.** `memsec` takes its pages from the Rust + global allocator and `mprotect`s them in place. A binary installing + `mimalloc`/`jemalloc`/`snmalloc` may hand it pages whose allocator + metadata sits inside the protected block; the failure mode is a + segfault or silently ineffective guard pages, on the secret path. +- **No Miri, ASan, LSan or libFuzzer over this crate.** Miri cannot + execute the `mprotect`/`mlock` FFI, and the sanitizers segfault on + memsec's guard pages (memsec issue #14). A sanitizer or fuzz job must + build without the `secrets` feature. The `unsafe` this forecloses + verification of is confined to `src/secrets/guarded.rs` and is small + enough to review by inspection — which is now the only line of + defence, and the reason it stays that small. ## Backup retention and secrets diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index 54c10e37dfb..16f3099b0c1 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -29,12 +29,11 @@ //! //! Enum-shaped TEXT columns (`network`, `account_type`, `pool_type`, //! `status`, `state`) carry a `CHECK (col IN (...))` clause whose -//! IN-list is built from the `*_LABELS` const arrays in -//! `crate::sqlite::schema::{wallet_meta, accounts, asset_locks, -//! contacts}`. The consts are the single source of truth shared with -//! the writer mapping functions; the per-module `*_labels_match_enum` -//! unit tests enforce set-equality between each const and its writer's -//! codomain. +//! IN-list is a FROZEN literal — never the live `*_LABELS` const it +//! mirrors. See the freeze rationale on [`migration`]. The live consts +//! stay the single source of truth for the writer mapping functions; a +//! `*_labels_frozen_in_v001` unit test per schema module pins each one +//! to its literal here. fn build_check_in(labels: &[&str]) -> String { let quoted = labels @@ -45,20 +44,39 @@ fn build_check_in(labels: &[&str]) -> String { format!("({})", quoted) } +/// Renders V001's DDL. +/// +/// Every `CHECK (col IN (...))` domain below is a FROZEN literal, and must +/// stay one. Interpolating a live `*_LABELS` const would let a later enum +/// variant rewrite this migration's generated SQL, breaking its Refinery +/// checksum on every database that already applied it (`abort_divergent` +/// defaults to true) — the database then fails to open, permanently, with +/// no in-crate recovery path. Widening a domain means APPENDING a migration +/// that rebuilds the table with the wider CHECK, as +/// `V004__asset_lock_recovered_status.rs` does; it never means editing a +/// list here. These lists are what `v4.2-dev` shipped, and the rendered SQL +/// is pinned byte-for-byte against it. pub fn migration() -> String { - let network_check = build_check_in(crate::sqlite::schema::wallet_meta::NETWORK_LABELS); - let account_type_check = - build_check_in(crate::sqlite::schema::accounts::ACCOUNT_TYPE_LABELS); - let pool_type_check = build_check_in(crate::sqlite::schema::accounts::POOL_TYPE_LABELS); - // FROZEN as of V004: the asset-lock status domain must no longer be - // interpolated from the live `ASSET_LOCK_STATUS_LABELS` const — a - // later variant addition would silently rewrite this migration's - // generated SQL and break its Refinery checksum on every database - // that already applied it (`abort_divergent` default). New status - // labels are introduced by APPENDING a migration that rebuilds the - // table with the widened CHECK (see - // `V004__asset_lock_recovered_status.rs`); this list stays - // byte-identical to what V001 shipped with. + let network_check = build_check_in(&["mainnet", "testnet", "devnet", "regtest"]); + let account_type_check = build_check_in(&[ + "standard", + "coinjoin", + "identity_registration", + "identity_topup", + "identity_topup_unbound", + "identity_invitation", + "asset_lock_address_topup", + "asset_lock_shielded_topup", + "provider_voting", + "provider_owner", + "provider_operator", + "provider_platform", + "dashpay_receiving", + "dashpay_external", + "platform_payment", + ]); + let pool_type_check = + build_check_in(&["external", "internal", "absent", "absent_hardened"]); let asset_lock_status_check = build_check_in(&[ "built", "broadcast", @@ -66,10 +84,13 @@ pub fn migration() -> String { "chain_locked", "consumed", ]); - let contact_state_check = - build_check_in(crate::sqlite::schema::contacts::CONTACT_STATE_LABELS); - let pending_contact_crypto_kind_check = - build_check_in(crate::sqlite::schema::pending_contact_crypto::KIND_LABELS); + let contact_state_check = build_check_in(&["sent", "received", "established"]); + let pending_contact_crypto_kind_check = build_check_in(&[ + "register_receiving", + "register_external", + "contact_info_decrypt", + "auto_accept", + ]); format!( "\ diff --git a/packages/rs-platform-wallet-storage/migrations/V008__rehydration_base_schema.rs b/packages/rs-platform-wallet-storage/migrations/V008__rehydration_base_schema.rs new file mode 100644 index 00000000000..d87e05b619c --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V008__rehydration_base_schema.rs @@ -0,0 +1,226 @@ +//! Reshape the V001 base schema for the rehydration workstream (#3968). +//! +//! Everything here was originally written into `V001__initial.rs` in place. +//! That is not available: `v4.2-dev` publishes V001-V007, and refinery +//! validates an applied migration's checksum against the embedded migration of +//! the same version, so editing a published body stops every database that +//! applied it from opening. The reshape therefore APPENDS, and V001-V007 stay +//! byte-identical to what `v4.2-dev` shipped. +//! +//! Seven changes, in dependency order: +//! +//! 1. Stamp the header `application_id`, so a wallet database is +//! distinguishable from a foreign refinery-versioned SQLite file. +//! 2. `wallet_metadata` -> `wallets`. With `legacy_alter_table` off (the +//! default since SQLite 3.25) this rewrites the FK clause of every +//! referencing table and the body and target of +//! `cascade_meta_on_wallet_delete`, so V003-V006's FK declarations follow +//! the rename without being edited. +//! 3. `account_registrations` gains the discriminators that keep distinct +//! accounts off one primary key, and its `account_type` domain widens to +//! the split standard labels while still admitting the pre-split one. +//! 4. Retain legacy pools and derived addresses until the typed conversion +//! after V011 can preserve their ownership, usage and public key material. +//! 5. `core_sync_state` gains the applied-ChainLock column. +//! 6. `identities.wallet_index` -> `identity_index`, which is what the column +//! always meant. +//! 7. `identity_keys` gains its own `wallet_id` scope and a compound FK, so a +//! key can only be filed under the wallet that owns its identity. +//! +//! Table rebuilds copy into an FK-declaring twin under +//! `PRAGMA foreign_keys = ON`, so each is preceded by an explicit orphan sweep +//! — the same policy, and the same reasoning, as +//! `V004__asset_lock_recovered_status.rs`. + +// The trigger pair created below is the PERMISSIVE original: it rejects a +// NULL-scoped key whose identity is wallet-owned, but accepts one naming an +// identity that does not exist at all. `V016` replaces it with the inverted +// condition that closes both cases. Deliberately not written in its final form +// here — V016 carries the fix and its own coverage, and collapsing the two +// would delete that coverage to save one migration. +pub fn migration() -> String { + // FROZEN, like V001's domains: never interpolate a live `*_LABELS` const + // into a migration. `account_type_labels_frozen_in_v007` pins the live + // const to this list, so an added variant fails a test with instructions + // rather than rewriting this body's checksum. + // + // `standard` is the pre-split label `v4.2-dev` wrote for BOTH standard + // variants. It is ADMITTED rather than rewritten: which variant such a row + // is lives in `account_xpub_bytes` and is not SQL-reachable, so any rewrite + // here would be a guess, and guessing wrong turns a row that loads today + // into a fatal mismatch under the default load policy. The reader carries + // the equivalence instead (`accounts::db_label_matches_entry`). + // + // A later save does NOT replace such a row: the upsert keys on + // `account_type`, so the writer's precise label inserts a sibling rather + // than updating it. The reader reconciles that pair, returning the account + // once and routing a disagreeing pair to `AccountRegistrationDrift`, so + // the surviving row costs a row and not a duplicate registration. + let account_type_check = build_check_in(&[ + "standard", + "standard_bip44", + "standard_bip32", + "coinjoin", + "identity_registration", + "identity_topup", + "identity_topup_unbound", + "identity_invitation", + "asset_lock_address_topup", + "asset_lock_shielded_topup", + "provider_voting", + "provider_owner", + "provider_operator", + "provider_platform", + "dashpay_receiving", + "dashpay_external", + "platform_payment", + ]); + // Splice the constant in decimal — `PRAGMA` takes no bound params. + let application_id = crate::sqlite::conn::APPLICATION_ID; + + format!( + "\ +PRAGMA application_id = {application_id}; + +ALTER TABLE wallet_metadata RENAME TO wallets; + +-- `account_registrations` rebuild. The widened primary key admits accounts +-- that shared (account_type, account_index) under the old one: the +-- PlatformPayment key_class, and the DashPay (user, friend) identity pair. +-- Sentinel defaults stand in for variants without that axis. +CREATE TABLE account_registrations_new ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}), + account_index INTEGER NOT NULL, + key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + account_xpub_bytes BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); + +-- Orphan policy: a row whose wallet was deleted while FK enforcement happened +-- to be off is unreachable garbage (every read path keys through `wallets`), +-- but copying it into the FK-declared twin aborts this whole migration with +-- 'FOREIGN KEY constraint failed'. Drop such rows explicitly — the same +-- outcome the declared ON DELETE CASCADE would have produced had enforcement +-- been on when the wallet was deleted. +DELETE FROM account_registrations WHERE wallet_id NOT IN (SELECT wallet_id FROM wallets); + +-- Labels are copied verbatim, legacy `standard` included. New rows use the +-- split labels; a pre-split row keeps the only label its column ever held, +-- and the blob it is paired with stays the sole authority on which standard +-- variant it is. +INSERT INTO account_registrations_new + (wallet_id, account_type, account_index, account_xpub_bytes) +SELECT wallet_id, account_type, account_index, account_xpub_bytes +FROM account_registrations; + +DROP TABLE account_registrations; +ALTER TABLE account_registrations_new RENAME TO account_registrations; + +-- Keep both legacy tables until the Rust conversion after V011. This also +-- permits stopping at V008-V010 and resuming after closing the connection. + +-- Bincode-encoded `dashcore::ephemerealdata::chain_lock::ChainLock`. +-- NULL until the first ChainLock has been applied and flushed. +ALTER TABLE core_sync_state ADD COLUMN last_applied_chain_lock BLOB; + +-- An identity's index in its wallet's identity sequence, which is what this +-- column always held; `wallet_index` read as an index OF a wallet. +ALTER TABLE identities RENAME COLUMN wallet_index TO identity_index; + +-- Parent key for `identity_keys`' compound FK. SQLite requires the referenced +-- columns to carry a UNIQUE index. Adds no new restriction: `identity_id` is +-- already PRIMARY KEY, so `(wallet_id, identity_id)` is unique for free. +CREATE UNIQUE INDEX idx_identities_wallet_identity ON identities(wallet_id, identity_id); + +-- `identity_keys` rebuild: the table gains its own wallet scope, so per-wallet +-- reads stay a direct `WHERE wallet_id = ?`, and a compound FK so a key can +-- only be filed under the wallet that OWNS the identity. The single-column +-- form allowed a key to name an identity parented to a different wallet — a +-- row the per-wallet reader can never resolve, surfacing much later as a fatal +-- OrphanedIdentityEntry. +-- +-- `wallet_id` is NULLABLE and deliberately NOT part of the key: NULL is the +-- canonical `owned by no wallet`, matching `identities.wallet_id`. SQLite's +-- default MATCH SIMPLE skips FK enforcement entirely when ANY column of the +-- child key is NULL, so for a NULL-scoped row BOTH FKs below are dormant and +-- neither constrains which identity the key names. The trigger pair after this +-- table replaces that dormancy; the FKs alone are NOT sufficient. +CREATE TABLE identity_keys_new ( + wallet_id BLOB, + identity_id BLOB NOT NULL, + key_id INTEGER NOT NULL, + public_key_blob BLOB NOT NULL, + public_key_hash BLOB NOT NULL, + -- Reserved for a future typed projection; always NULL today. + -- derivation_indices lives inside public_key_blob (the IdentityKeyWire + -- blob is the single source of truth). + derivation_blob BLOB, + PRIMARY KEY (identity_id, key_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE, + FOREIGN KEY (wallet_id, identity_id) + REFERENCES identities(wallet_id, identity_id) ON DELETE CASCADE +); + +-- Same orphan policy as above: a key naming an identity that no longer exists +-- is unreachable, and would abort the copy against the re-declared FK. +DELETE FROM identity_keys WHERE identity_id NOT IN (SELECT identity_id FROM identities); + +-- The scope is denormalised from the identity that owns the key, which is the +-- only value the compound FK will accept. +INSERT INTO identity_keys_new + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash) +SELECT i.wallet_id, k.identity_id, k.key_id, k.public_key_blob, k.public_key_hash +FROM identity_keys k +JOIN identities i ON i.identity_id = k.identity_id; + +DROP TABLE identity_keys; +ALTER TABLE identity_keys_new RENAME TO identity_keys; + +CREATE INDEX idx_identity_keys_wallet_identity ON identity_keys(wallet_id, identity_id); + +-- The NULL-scope guard the dormant FKs cannot provide: a key filed as unowned +-- must name an identity that is itself unowned. Without this a NULL-scoped key +-- could name a wallet-OWNED identity — the corruption shape the compound FK +-- was added to stop, re-entering through the NULL door. +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity +BEFORE INSERT ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the identity is wallet-owned') + WHERE EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NOT NULL + ); +END; + +-- Necessary twin, NOT a redundant copy — do not simplify away. The primary key +-- is (identity_id, key_id), so the writer's upsert resolves an existing key to +-- DO UPDATE, and an UPDATE never fires a BEFORE INSERT trigger. Without this +-- one the guard above is bypassed by the ordinary re-save path, which is the +-- path real writes take. +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity_on_update +BEFORE UPDATE ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the identity is wallet-owned') + WHERE EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NOT NULL + ); +END; +" + ) +} + +fn build_check_in(labels: &[&str]) -> String { + let quoted = labels + .iter() + .map(|l| format!("'{}'", l)) + .collect::>() + .join(", "); + format!("({})", quoted) +} diff --git a/packages/rs-platform-wallet-storage/migrations/V009__unified.rs b/packages/rs-platform-wallet-storage/migrations/V009__unified.rs new file mode 100644 index 00000000000..f74a645b2ba --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V009__unified.rs @@ -0,0 +1,98 @@ +//! Unified additive migration for `platform-wallet-storage` (#3968). +//! +//! Sequenced after the seven migrations `v4.2-dev` already ships (V001-V007), +//! whose version numbers are owned by merged history and must never be +//! reassigned — refinery keys `refinery_schema_history` by version and +//! validates the applied checksum against the embedded migration of the same +//! version, so reusing one of those numbers for different DDL stops every +//! database that applied the original from opening. +//! +//! Lands three concerns in one migration event: +//! +//! - `core_address_pool` — per-index address-pool rows with a `used` flag, +//! the first-class row store that replaces `core_utxos` script-derivation +//! for the address-reuse guard. `account_type` and `pool_type` are both in +//! the primary key: `account_type` so two accounts that collapse to the same +//! `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` and +//! `ProviderVotingKeys`, both `0, 0`) never overwrite each other, and +//! `pool_type` so an External (receive) and Internal (change) pool never +//! collide at the same `address_index`. The PK also carries the DashPay +//! `(user_identity_id, friend_identity_id)` pair, mirroring +//! `account_registrations` (V001): `DashpayReceivingFunds` accounts all +//! collapse to `(account_type='dashpay_receiving', account_index=0)`, so +//! without the identity pair two contacts on one wallet would upsert onto +//! the same PK and silently overwrite each other's pool rows. `script` (the +//! address' `script_pubkey`) is stored so the reader returns used addresses +//! verbatim and the UTXO writer can attribute an outpoint to its owning +//! account, both without re-deriving. +//! - `meta_data_versions` — per-`(wallet_id, domain)` monotonic `seq` +//! bumped inside the flush transaction, the cache-invalidation keystone. +//! No FK (a domain row may be written before its typed parent syncs, +//! mirroring the `meta_*` tables); a soft-cascade trigger reaps rows on +//! wallet delete. +//! - `meta_store_generation` — a single-row store-generation token, +//! initialized with `randomblob(16)` so the rendered SQL stays deterministic (the +//! content fingerprint pins the text, the runtime value is unique per +//! store). Regenerated on restore. +//! +//! No MAC column ships here — manifest authentication is deferred out of +//! this workstream (dev-plan §7). + +// INTENTIONAL(account-type-unconstrained): `core_address_pool.account_type` +// is plain `TEXT NOT NULL` with no `CHECK` allow-list. A constraint was +// considered and deferred as low-priority defence-in-depth — the column is +// written only from a closed Rust enum's own label, never from user input, +// and the readers reject an unknown label anyway. A future CHECK belongs in a +// new migration rather than an edit here: editing a rendered body is the +// schema-drift alarm `sqlite_schema_pinning` exists to raise. +pub fn migration() -> String { + "\ +CREATE TABLE core_address_pool ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL, + account_index INTEGER NOT NULL, + key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)), + address_index INTEGER NOT NULL, + script BLOB NOT NULL, + used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)), + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id, pool_type, address_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); + +CREATE INDEX idx_core_address_pool_used + ON core_address_pool(wallet_id, used); + +-- The UTXO writer attributes an outpoint to its owning account by matching +-- the outpoint's script against a pool row. +CREATE INDEX idx_core_address_pool_script + ON core_address_pool(wallet_id, script); + +CREATE TABLE meta_data_versions ( + wallet_id BLOB NOT NULL, + domain TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, domain) +); + +-- Soft-cascade reap, matching the meta_* tables: no FK (a domain may be +-- bumped before its typed parent exists), so a trigger clears rows when +-- the owning wallet is deleted. +CREATE TRIGGER cascade_meta_data_versions_on_wallet_delete +AFTER DELETE ON wallets +FOR EACH ROW +BEGIN + DELETE FROM meta_data_versions WHERE wallet_id = OLD.wallet_id; +END; + +CREATE TABLE meta_store_generation ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + generation BLOB NOT NULL +); + +INSERT INTO meta_store_generation (id, generation) VALUES (0, randomblob(16)); +" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V010__pool_public_key.rs b/packages/rs-platform-wallet-storage/migrations/V010__pool_public_key.rs new file mode 100644 index 00000000000..90df5523c2e --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V010__pool_public_key.rs @@ -0,0 +1,13 @@ +//! Persist typed public keys carried by address-pool rows (dashpay/platform#4113). +//! +//! This closes the gap where pre-derived platform-node Ed25519 keys were silently +//! dropped during SQLite round-trips. SLIP-10 supports hardened derivation only, +//! so a watch-only account xpub cannot regenerate them. Nullable key bytes and a +//! curve discriminator preserve typed entries; existing rows and snapshots whose +//! `AddressInfo` has no public key remain NULL in both columns. + +pub fn migration() -> String { + "ALTER TABLE core_address_pool ADD COLUMN public_key BLOB NULL; +ALTER TABLE core_address_pool ADD COLUMN key_type INTEGER NULL CHECK (key_type IS NULL OR key_type IN (0, 1, 2));" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V011__pool_reserved_at.rs b/packages/rs-platform-wallet-storage/migrations/V011__pool_reserved_at.rs new file mode 100644 index 00000000000..3ddce8d1da2 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V011__pool_reserved_at.rs @@ -0,0 +1,8 @@ +//! Persist address-pool reservation timestamps (dashpay/platform#4188). +//! +//! A nullable timestamp preserves `AddressState::Reserved` while keeping +//! available and used rows unreserved. + +pub fn migration() -> String { + "ALTER TABLE core_address_pool ADD COLUMN reserved_at INTEGER NULL;".to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V012__drop_core_utxo_metadata.rs b/packages/rs-platform-wallet-storage/migrations/V012__drop_core_utxo_metadata.rs new file mode 100644 index 00000000000..608613ed6c4 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V012__drop_core_utxo_metadata.rs @@ -0,0 +1,11 @@ +//! Remove `core_utxos` metadata that is not part of production rehydration. + +pub fn migration() -> String { + "\ +DROP TRIGGER setnull_core_utxos_on_tx_delete; + +ALTER TABLE core_utxos DROP COLUMN account_index; + +" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V013__shielded_viewing_keys.rs b/packages/rs-platform-wallet-storage/migrations/V013__shielded_viewing_keys.rs new file mode 100644 index 00000000000..3a9fd4639c2 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V013__shielded_viewing_keys.rs @@ -0,0 +1,14 @@ +//! Persist Orchard full viewing keys by wallet and shielded account. + +pub fn migration() -> String { + "\ +CREATE TABLE shielded_viewing_keys ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL CHECK (account_index BETWEEN 0 AND 4294967295), + viewing_key BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V014__single_source_core_confirmation_height.rs b/packages/rs-platform-wallet-storage/migrations/V014__single_source_core_confirmation_height.rs new file mode 100644 index 00000000000..6634fa85514 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V014__single_source_core_confirmation_height.rs @@ -0,0 +1,73 @@ +//! Single-source UTXO confirmation height in `core_transactions` (#4178). +//! +//! Rebuilding `core_transactions` relaxes `record_blob NOT NULL`. A height-only +//! row carries a recordless UTXO's confirmation height without block context. +//! Legacy writers used height zero as the unconfirmed sentinel, so only positive +//! heights are safe to backfill; post-migration rows use `NULL` for unconfirmed. + +// INTENTIONAL(outpoint-txid-prefix): `substr(u.outpoint, 2, 32)` lifts the txid +// out of an encoded outpoint by skipping a single length-prefix byte ahead of +// the 32 txid bytes. `encode_outpoint_txid_occupies_bytes_two_to_thirty_three` +// pins that layout, so a change in the encoding fails a test here rather than +// silently backfilling 32 bytes of the wrong field. + +pub fn migration() -> String { + "\ +CREATE TABLE core_transactions_new ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + height INTEGER, + block_hash BLOB, + block_time INTEGER, + finalized INTEGER NOT NULL, + record_blob BLOB, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); + +-- Orphan policy: a row whose wallet was deleted while FK enforcement happened +-- to be off is unreachable garbage (every read path keys through `wallets`), +-- but copying it into the FK-declared twin under PRAGMA foreign_keys = ON +-- aborts this whole migration with 'FOREIGN KEY constraint failed'. Drop such +-- rows explicitly -- the same outcome the declared ON DELETE CASCADE would +-- have produced had enforcement been on when the wallet was deleted. Both +-- source tables need it: `core_utxos` feeds the height-only backfill below. +DELETE FROM core_transactions WHERE wallet_id NOT IN (SELECT wallet_id FROM wallets); +DELETE FROM core_utxos WHERE wallet_id NOT IN (SELECT wallet_id FROM wallets); + +INSERT INTO core_transactions_new + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) +SELECT wallet_id, txid, height, block_hash, block_time, finalized, record_blob +FROM core_transactions; + +DROP TABLE core_transactions; +ALTER TABLE core_transactions_new RENAME TO core_transactions; + +CREATE INDEX idx_core_transactions_height ON core_transactions(wallet_id, height); + +INSERT INTO core_transactions + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) +SELECT u.wallet_id, substr(u.outpoint, 2, 32), u.height, NULL, NULL, 0, NULL +FROM core_utxos u +WHERE u.spent = 0 AND u.height IS NOT NULL AND u.height > 0 + AND NOT EXISTS ( + SELECT 1 FROM core_transactions t + WHERE t.wallet_id = u.wallet_id AND t.txid = substr(u.outpoint, 2, 32) + ) +ON CONFLICT(wallet_id, txid) DO UPDATE SET height = excluded.height +WHERE core_transactions.record_blob IS NULL AND core_transactions.height IS NULL; + +DROP INDEX idx_core_utxos_unmaterialized; +ALTER TABLE core_utxos ADD COLUMN is_sweep_placeholder INTEGER NOT NULL DEFAULT 0; +UPDATE core_utxos SET is_sweep_placeholder = 1 WHERE height IS NULL; +ALTER TABLE core_utxos DROP COLUMN height; +CREATE INDEX idx_core_utxos_unmaterialized ON core_utxos(wallet_id, winner_mined_height) + WHERE is_sweep_placeholder = 1; +CREATE TRIGGER setnull_core_utxos_on_tx_delete AFTER DELETE ON core_transactions +BEGIN + UPDATE core_utxos SET spent_in_txid = NULL + WHERE wallet_id = OLD.wallet_id AND spent_in_txid = OLD.txid; +END; +" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V015__purge_legacy_empty_script_spent_utxos.rs b/packages/rs-platform-wallet-storage/migrations/V015__purge_legacy_empty_script_spent_utxos.rs new file mode 100644 index 00000000000..33ddc623176 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V015__purge_legacy_empty_script_spent_utxos.rs @@ -0,0 +1,24 @@ +//! Purge legacy `core_utxos` rows carrying an empty `script`. +//! +//! A spend recorded before the producer reconstructed the previous +//! output's script from its typed address left a `spent = 1` row with +//! `script = X''`. `core_state::load_used_addresses` decodes EVERY stored +//! script — spent and unspent — so under the default `LoadPolicy::Strict` +//! one such row rejects the load of the entire database file, every +//! wallet in it included; only `LoadPolicy::Recovery` tolerates it. +//! Nothing overwrites the row either: the spend path only ever flips +//! `spent` on a row that already exists. +//! +//! Deleting these rows is balance-neutral — the balance readers +//! (`load_state`, `list_unspent_utxos`) select `spent = 0` only — and +//! costs the address-reuse guard, the sole consumer of a spent row's +//! script, nothing: an empty script decodes to no address, so such a row +//! contributes no entry to the used-set today, only the failure. +//! +//! The predicate is exact. `script` is `NOT NULL`, so empty is its only +//! degenerate value; and `spent = 1` is load-bearing — an unspent row is +//! balance state and stays whatever its script holds. + +pub fn migration() -> String { + "DELETE FROM core_utxos WHERE spent = 1 AND length(script) = 0 AND is_sweep_placeholder = 0;".to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V016__identity_keys_null_scope_requires_existing_identity.rs b/packages/rs-platform-wallet-storage/migrations/V016__identity_keys_null_scope_requires_existing_identity.rs new file mode 100644 index 00000000000..d34879926f2 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V016__identity_keys_null_scope_requires_existing_identity.rs @@ -0,0 +1,52 @@ +//! Close the asymmetry in the `identity_keys` NULL-scope guard. +//! +//! V008's trigger pair aborts a NULL-scoped key whose identity is +//! wallet-owned, but accepts one naming an identity that does not exist at +//! all — `EXISTS(... AND wallet_id IS NOT NULL)` is false for a missing row +//! just as it is for an unowned one. SQLite's MATCH SIMPLE leaves both of +//! the table's foreign keys dormant whenever any child-key column is NULL, +//! so nothing else constrains a NULL-scoped row either: the triggers are +//! the whole guard, and that gap admits a key belonging to no identity. +//! +//! The condition is inverted to require the named identity to exist AND be +//! unowned, which covers the wallet-owned case the original caught and the +//! missing-identity case it did not. +//! +//! Recreated rather than edited into V008: refinery never re-runs an +//! applied migration, so editing V008 would tighten only freshly created +//! databases and leave every existing wallet on the permissive trigger. +//! `V012__drop_core_utxo_metadata` sets the same precedent. +//! +//! The UPDATE twin is as load-bearing as the INSERT one — the writer's +//! upsert resolves an existing key to `DO UPDATE`, and an UPDATE never +//! fires a BEFORE INSERT trigger — so both move together. + +pub fn migration() -> String { + "\ +DROP TRIGGER identity_keys_null_scope_requires_unowned_identity; +DROP TRIGGER identity_keys_null_scope_requires_unowned_identity_on_update; + +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity +BEFORE INSERT ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the named identity is missing or wallet-owned') + WHERE NOT EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NULL + ); +END; + +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity_on_update +BEFORE UPDATE ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the named identity is missing or wallet-owned') + WHERE NOT EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NULL + ); +END; +" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/migrations/V017__identity_scan_state.rs b/packages/rs-platform-wallet-storage/migrations/V017__identity_scan_state.rs new file mode 100644 index 00000000000..441b5a6ea41 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V017__identity_scan_state.rs @@ -0,0 +1,57 @@ +//! Add the `identity_scan_states` + `identity_scan_failed_indices` tables +//! (verdict of the last gap-limit identity scan). +//! +//! One row per wallet, holding what the last scan probed and what it could +//! not answer. Without it the verdict lived only for the life of the process: +//! a scan that found an identity while one of its probes went unanswered +//! reported success, the next launch saw an identity on file and took the +//! warm-launch shortcut, and the identity at the unanswered index stayed +//! invisible for the life of the installation (dashpay/platform#4365). +//! +//! The entry is all-primitive apart from its list of unanswered indices, so +//! everything maps to explicit columns and no opaque blob is needed (the +//! `dpns_name_states` precedent). The list gets its own child table rather +//! than a packed column: the composite primary key is what makes "ascending, +//! no duplicates" a schema invariant instead of a writer convention. +//! +//! `complete` is stored rather than derived because the two ways a scan ends +//! early differ — unanswered probes leave indices behind, while a scan +//! abandoned at the startup budget leaves none and is no more complete for +//! it. `unlocated_gap` records that second kind, which by definition has no +//! index to name it. +//! +//! Purely additive: an upgraded database gains two empty tables, every wallet +//! reads back "no verdict recorded", and upstream treats that absence as +//! "keep the existing behaviour" rather than "rescan". Nothing is backfilled +//! because nothing could be — no earlier schema held the fact. +//! +//! The intra-row half of the completeness invariant is a CHECK here; the +//! cross-table half (`complete` against a non-empty index list) cannot be, +//! and is enforced by the reader. + +pub fn migration() -> String { + "\ +CREATE TABLE identity_scan_states ( + wallet_id BLOB NOT NULL PRIMARY KEY, + complete INTEGER NOT NULL CHECK (complete IN (0, 1)), + probed_from INTEGER NOT NULL CHECK (probed_from >= 0), + probed_through INTEGER NOT NULL CHECK (probed_through >= probed_from), + unlocated_gap INTEGER NOT NULL CHECK (unlocated_gap IN (0, 1)), + -- A scan cannot both have answered everything and be sitting on a gap + -- nobody could name. + CHECK (complete = 0 OR unlocated_gap = 0), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); + +-- Indices this wallet's scans probed without getting an answer. Parented on +-- the verdict rather than on `wallets` so clearing a verdict clears its gaps +-- in one statement; the wallet cascade still reaches here transitively. +CREATE TABLE identity_scan_failed_indices ( + wallet_id BLOB NOT NULL, + failed_index INTEGER NOT NULL CHECK (failed_index >= 0), + PRIMARY KEY (wallet_id, failed_index), + FOREIGN KEY (wallet_id) REFERENCES identity_scan_states(wallet_id) ON DELETE CASCADE +); +" + .to_string() +} 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 index 17ff3d0ba52..ddd7e76fec0 100644 --- a/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs +++ b/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs @@ -3,6 +3,7 @@ //! Output convention: stdout = data; stderr = diagnostics + error //! messages (lower-cased, no trailing period, single line). +use std::error::Error; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::Duration; @@ -74,7 +75,7 @@ struct RestoreArgs { #[arg(long)] yes: bool, /// Skip the pre-restore auto-backup of the live destination DB. - /// Without this, the persister writes `pre-restore-.db` to + /// Without this, the persister writes `pre-restore--.db` to /// `--auto-backup-dir` before clobbering the destination. #[arg(long)] no_auto_backup: bool, @@ -138,6 +139,12 @@ impl CliError { code: ExitCode::from(1), } } + fn usage(msg: impl Into) -> Self { + Self { + message: msg.into(), + code: ExitCode::from(2), + } + } fn validation(msg: impl Into) -> Self { Self { message: msg.into(), @@ -146,32 +153,43 @@ impl CliError { } } +/// Render `err` and its full `#[source]` chain, joined by `": "`. +/// +/// `WalletStorageError`'s `Display` is deliberately terse for variants that +/// keep their detail in `#[source]` (`error.rs:1-4`) — "sqlite error", +/// "migration error", "cannot open candidate source database" and friends +/// carry nothing an operator can act on by themselves. The CLI is the one +/// place all of that detail needs to reach a human, so every path here +/// walks the chain back out instead of stopping at the head. +fn chain_message(err: &dyn Error) -> String { + let mut out = err.to_string(); + let mut cur = err.source(); + while let Some(source) = cur { + out.push_str(": "); + out.push_str(&source.to_string()); + cur = source.source(); + } + out +} + fn run(cli: Cli) -> Result { let auto_backup_dir: Option = cli.auto_backup_dir; - // `prune` is a pure filesystem op against the backups directory — - // `--db` is meaningless for it and must not be required. Handle the - // subcommand BEFORE extracting `cli.db` so the operator can run - // `prune --backups-dir ... --keep-last N` without - // also passing a database path. + // `prune` is a pure filesystem op; `--db` is meaningless, so handle it + // before requiring `cli.db`. if let Cmd::Prune(args) = &cli.cmd { return run_prune(args); } - let db = cli - .db - .ok_or_else(|| CliError::runtime("--db is required"))?; + let db = cli.db.ok_or_else(|| CliError::usage("--db is required"))?; // `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_deref()); } - // 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. + // `migrate --no-auto-backup` clears `auto_backup_dir` so the open-time + // pre-migration backup is skipped; other subcommands keep the default. let mut config = SqlitePersisterConfig::new(&db); if let Some(dir) = auto_backup_dir.clone() { config = config.with_auto_backup_dir(Some(dir)); @@ -183,15 +201,14 @@ fn run(cli: Cli) -> Result { } } - // 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. + // Migrate is done by `open`; capture pre/post versions to print + // "applied: N". A read failure must surface, not be read as 0. if let Cmd::Migrate(_) = &cli.cmd { - let pre_version = peek_schema_version(&db).map_err(|e| CliError::runtime(e.to_string()))?; + let pre_version = + peek_schema_version(&db).map_err(|e| CliError::runtime(chain_message(&e)))?; 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()))?; + peek_schema_version(&db).map_err(|e| CliError::runtime(chain_message(&e)))?; let applied = post_version .unwrap_or(0) .saturating_sub(pre_version.unwrap_or(0)) as usize; @@ -217,8 +234,10 @@ fn map_open_err_for_cli(err: WalletStorageError) -> CliError { .to_string(), code: ExitCode::from(1), }, - WalletStorageError::Io(e) => CliError::runtime(format!("failed to open database: {e}")), - other => CliError::runtime(other.to_string()), + WalletStorageError::Io(e) => { + CliError::runtime(format!("failed to open database: {}", chain_message(&e))) + } + other => CliError::runtime(chain_message(&other)), } } @@ -228,21 +247,16 @@ fn map_open_err_for_cli(err: WalletStorageError) -> CliError { /// transient failure for "version 0". fn peek_schema_version(db: &Path) -> Result, rusqlite::Error> { use rusqlite::{OpenFlags, OptionalExtension}; - // Open READ-ONLY (no SQLITE_OPEN_CREATE) so a typo'd --db path errors - // out at this gate rather than silently materialising a zero-byte - // SQLite file that bypasses the crate's 0o600 invariant. A genuinely - // fresh `migrate` invocation against a non-existent DB file is normal - // — surface that as `Ok(None)` so the migrate path proceeds and - // `SqlitePersister::open` creates the file under the 0o600 invariant. + // A missing path is a normal fresh `migrate`: `Ok(None)` lets + // `SqlitePersister::open` create the file under the 0o600 invariant, + // instead of materialising a stub here that bypasses it. if !db.exists() { return Ok(None); } - let conn = rusqlite::Connection::open_with_flags( - db, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, - )?; - // Pre-migration the history table may not exist yet — that is a - // legitimate "no version" answer, not a failure. + // READ-ONLY, URI parsing off (matches the open-conn choke-point) so a + // `--db` path can't smuggle `file:` query params defeating read-only. + let conn = rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + // Pre-migration the history table may legitimately not exist. let has_history = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", @@ -266,14 +280,13 @@ fn peek_schema_version(db: &Path) -> Result, rusqlite::Error> { } 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. + // `backup_to` owns refuse-to-overwrite (`BackupDestinationExists`). 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()), + other => CliError::runtime(chain_message(&other)), })?; println!("{}", path.display()); Ok(ExitCode::SUCCESS) @@ -285,18 +298,14 @@ fn run_restore( auto_backup_dir: Option<&Path>, ) -> Result { if !args.yes { - return Err(CliError { - message: "refusing to restore without --yes".into(), - code: ExitCode::from(2), - }); + return Err(CliError::usage("refusing to restore without --yes")); } 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. + // No persister is opened here, so compute the config default + // (`/backups/auto/`) inline. let resolved_dir: PathBuf = match auto_backup_dir { None => default_auto_backup_dir(db), Some(p) => p.to_path_buf(), @@ -308,29 +317,37 @@ fn run_restore( Err(WalletStorageError::IntegrityCheckFailed { report }) => Err(CliError::validation( format!("source backup failed integrity check: {report}"), )), + Err(err @ WalletStorageError::IntegrityCheckRunFailed { .. }) => { + Err(CliError::validation(chain_message(&err))) + } Err(WalletStorageError::SchemaHistoryMissing) => Err(CliError::validation( - "source backup failed integrity check: schema history missing".to_string(), + "source backup schema history missing".to_string(), )), + Err( + err @ (WalletStorageError::NotAWalletDb { .. } + | WalletStorageError::SchemaVersionUnsupported { .. } + | WalletStorageError::SchemaHistoryMalformed { .. } + | WalletStorageError::SourceOpenFailed { .. }), + ) => Err(CliError::validation(chain_message(&err))), 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())), + Err(other) => Err(CliError::runtime(chain_message(&other))), } } 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), - }); + return Err(CliError::usage( + "at least one of --keep-last or --max-age is required", + )); } 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()))?; + let report = platform_wallet_storage::prune_backups_in(&args.in_dir, policy) + .map_err(|e| CliError::runtime(chain_message(&e)))?; for p in &report.removed { println!("{}", p.display()); } @@ -350,10 +367,61 @@ fn run_prune(args: &PruneArgs) -> Result { mod tests { use super::*; - /// `peek_schema_version` on a non-existent path must NOT materialise - /// a zero-byte SQLite file at that path — opening READ-ONLY (no - /// SQLITE_OPEN_CREATE) keeps a typo from being rewarded with a stub - /// file lacking the crate's 0o600 mode invariant. + /// `chain_message` must not stop at the head of the chain — that is + /// the whole point of rendering it: `WalletStorageError`'s `Display` is + /// terse by design, so nothing this CLI prints can rely on `to_string`. + #[test] + fn chain_message_joins_the_whole_source_chain() { + #[derive(Debug)] + struct Leaf; + impl std::fmt::Display for Leaf { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "leaf cause") + } + } + impl std::error::Error for Leaf {} + + #[derive(Debug)] + struct Mid(Leaf); + impl std::fmt::Display for Mid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "mid layer") + } + } + impl std::error::Error for Mid { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } + } + + assert_eq!(chain_message(&Mid(Leaf)), "mid layer: leaf cause"); + } + + /// End-to-end through a real crate error: `WalletStorageError::Io`'s + /// `Display` is the bare word "io error" (`error.rs:38`); the operator + /// only learns anything from the wrapped `io::Error`. + #[test] + fn chain_message_surfaces_the_io_error_wrapped_by_wallet_storage_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"); + let err = WalletStorageError::Io(io_err); + assert_eq!(chain_message(&err), "io error: permission denied"); + } + + /// `map_open_err_for_cli`'s `Io` special case must still route through + /// `chain_message`, not a bare `{e}` that happens to work only because + /// `io::Error` rarely nests further. + #[test] + fn map_open_err_for_cli_io_variant_keeps_the_inner_message() { + let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"); + let cli_err = map_open_err_for_cli(WalletStorageError::Io(io_err)); + assert_eq!( + cli_err.message, + "failed to open database: permission denied" + ); + } + + /// `peek_schema_version` on a missing path must not materialise a stub + /// file (opening READ-ONLY) that would lack the 0o600 invariant. #[test] fn peek_schema_version_on_missing_db_does_not_create_stub() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/packages/rs-platform-wallet-storage/src/kv.rs b/packages/rs-platform-wallet-storage/src/kv.rs index 8dd1e3d22a1..4ec5bb6c12e 100644 --- a/packages/rs-platform-wallet-storage/src/kv.rs +++ b/packages/rs-platform-wallet-storage/src/kv.rs @@ -10,19 +10,14 @@ //! serialization (bincode, JSON, protobuf, raw bytes). Keys are //! bounded `TEXT` (1..=128 chars). //! -//! Scoping: each [`ObjectId`] variant addresses a dedicated table. The -//! [`ObjectId::Global`] slot has no parent and survives wallet deletion. -//! Every other variant names a wallet object, but a write does NOT -//! require that object to exist yet — metadata may be attached ahead of -//! sync. When the object is later deleted, an `AFTER DELETE` trigger on -//! its parent table removes the matching metadata. However, if the -//! parent object is never created, or is removed via a path the trigger -//! does not cover, the metadata row may persist as an orphan. This is an -//! accepted limitation across all scopes; a future garbage-collection pass -//! is expected to reap such orphans (no live parent, e.g. older than ~1 -//! week) — callers should not rely on orphan metadata persisting forever. -//! The same key string under different scopes is independent — the scopes -//! live in separate tables. +//! Scoping: each [`ObjectId`] variant addresses a dedicated table, so the +//! same key string under different scopes is independent. +//! [`ObjectId::Global`] has no parent and survives wallet deletion. Other +//! variants name a wallet object but a write does NOT require it to exist +//! yet (metadata may be attached ahead of sync); an `AFTER DELETE` trigger +//! reaps the metadata when the object is deleted. Rows whose parent is +//! never created, or removed via a path the trigger misses, may persist as +//! orphans — an accepted limitation; orphan reaping is not currently planned. //! //! This API is **independent of [`platform_wallet::changeset::PlatformWalletPersistence`]**: //! KV is for app metadata, not wallet domain state. Reads and writes go @@ -33,16 +28,10 @@ use platform_wallet::wallet::platform_wallet::WalletId; /// Scope of a metadata entry — one variant per dedicated `meta_*` table. /// -/// [`ObjectId::Global`] has no parent and survives wallet deletion. The -/// other variants name a wallet object but carry no insert-time -/// existence requirement: metadata may be written before its parent -/// object is synced into its typed table. An `AFTER DELETE` trigger on -/// each parent removes the matching metadata when the object is deleted. -/// -/// **Orphan metadata:** if the parent object is never created, or is -/// removed via a path the trigger does not cover, the metadata row may -/// persist as an orphan. A future GC pass is expected to reap such -/// rows; do not rely on them living forever. +/// [`ObjectId::Global`] has no parent and survives wallet deletion. Other +/// variants name a wallet object but may be written before it is synced; +/// an `AFTER DELETE` trigger reaps the metadata when the object is deleted. +/// See the module docs for the orphan-metadata limitation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ObjectId { /// Global app metadata; no parent (`meta_global`). @@ -69,25 +58,17 @@ pub enum ObjectId { }, } -/// Maximum allowed key length. Enforced in Rust as a **byte**-length -/// bound (`validate_key` rejects with `KeyTooLong`/`KeyEmpty` on -/// `key.len()`) and in SQL as a **code-point** bound -/// (`CHECK (length(key) BETWEEN 1 AND 128)`, where SQLite's `length()` -/// counts UTF-8 code points). For ASCII keys the two coincide; for -/// non-ASCII keys the Rust byte bound is the stricter of the two, so no -/// over-length key reaches SQL. +/// Maximum allowed key length, in **code points**. `validate_key` counts +/// `chars().count()`; the SQL `CHECK (length(key) BETWEEN 1 AND 128)` uses +/// the same unit (SQLite `length()` counts code points), so the two bounds +/// accept exactly the same key set. pub const MAX_KEY_LEN: usize = 128; /// Hard cap on the size of a single KV value, in bytes, so a tampered or /// corrupted backup row cannot force a multi-gigabyte allocation on the -/// next `get`. -/// -/// Kept in sync MANUALLY with the `BLOB_SIZE_LIMIT_BYTES` ceiling on -/// bincode-serde blobs in `sqlite::schema::blob`: the `sqlite` and `kv` -/// features compile independently, so a `const`-level cross-reference -/// between the two modules can't be relied on. Change both together if -/// the ceiling moves. -pub const MAX_VALUE_LEN: usize = 16 * 1024 * 1024; +/// next `get`. Shares the crate-root [`SIZE_LIMIT_BYTES`](crate::SIZE_LIMIT_BYTES) +/// ceiling with the bincode-serde BLOB decode cap. +pub const MAX_VALUE_LEN: usize = crate::SIZE_LIMIT_BYTES; /// Errors returned by [`KvStore`] operations. /// @@ -99,14 +80,20 @@ pub enum KvError { #[error("kv key is empty")] KeyEmpty, - /// Key exceeded [`MAX_KEY_LEN`]. - #[error("kv key too long: {len} bytes (max {})", MAX_KEY_LEN)] + /// Key contained an embedded NUL (`\0`). SQLite `length()` counts only + /// the bytes before the first NUL, so a NUL-bearing key would break the + /// `chars().count()` == SQLite `length()` invariant the CHECK relies on. + #[error("kv key contains an embedded NUL")] + KeyContainsNul, + + /// Key exceeded [`MAX_KEY_LEN`]. `len` is the key's code-point count + /// (the same unit the SQL `length()` CHECK uses). + #[error("kv key too long: {len} code points (max {})", MAX_KEY_LEN)] KeyTooLong { len: usize }, /// A value exceeded [`MAX_VALUE_LEN`]. Raised by `put` before the - /// INSERT and by `get` before the bytes are materialised, so an - /// oversize value never lands and a tampered row never OOMs the - /// process. + /// INSERT and by `get` before materialising, so a tampered row can't + /// OOM the process. #[error("kv value too large: {found} bytes (max {max})")] ValueTooLarge { found: usize, max: usize }, @@ -118,12 +105,30 @@ pub enum KvError { /// Mirrors [`crate::sqlite::error::WalletStorageError::LockPoisoned`]. #[error("persister lock poisoned")] LockPoisoned, + + /// A `put` / `delete` was attempted while the backing store is open + /// read-only for recovery. Reads stay available. `operation` names the + /// blocked entry point. + #[error( + "`{operation}` is blocked: the backing store is open in recovery mode (read-only) — \ + repair the database, then reopen it under the strict load policy to write again" + )] + ReadOnlyRecoveryMode { operation: &'static str }, } /// Per-object-type key/value metadata store. /// /// See the module-level docs for scoping and value semantics. Each /// [`ObjectId`] variant addresses a dedicated table. +/// +/// # Security +/// +/// Values are stored **PLAINTEXT** in the persister `.db` and in every +/// backup copied from it. This API is the explicit, caller-policed +/// plaintext exception to the crate's no-secrets-in-the-db boundary +/// (see `SECRETS.md`). **NEVER store key or signing material here** — +/// mnemonics, seeds, private keys, or anything that could move funds. +/// Use [`SecretStore`](crate::secrets::SecretStore) for secret material. pub trait KvStore { /// Read the value bound to `(scope, key)`. Returns `Ok(None)` when /// the key is absent. Backends MUST reject values larger than @@ -140,6 +145,12 @@ pub trait KvStore { /// Backends MUST reject a `value` larger than [`MAX_VALUE_LEN`] with /// [`KvError::ValueTooLarge`] before writing, so a `put` can never /// plant a row a later `get` would refuse to materialise. + /// + /// # Security + /// + /// `value` is stored **PLAINTEXT** in the `.db` and all backups. + /// NEVER store key/signing material here — use + /// [`SecretStore`](crate::secrets::SecretStore). fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError>; /// Remove the row bound to `(scope, key)`. Idempotent — a missing @@ -156,14 +167,21 @@ pub trait KvStore { fn list_keys(&self, scope: &ObjectId, prefix: Option<&str>) -> Result, KvError>; } -/// Validate a key against the length bounds. Used by [`KvStore`] -/// implementations as a typed-error pre-check before reaching SQL. +/// Typed-error pre-check used by [`KvStore`] impls before reaching SQL. +/// Counts code points to match the SQL CHECK unit (see [`MAX_KEY_LEN`]). pub(crate) fn validate_key(key: &str) -> Result<(), KvError> { if key.is_empty() { return Err(KvError::KeyEmpty); } - if key.len() > MAX_KEY_LEN { - return Err(KvError::KeyTooLong { len: key.len() }); + // An embedded NUL truncates SQLite's `length()` (and string comparisons), + // so reject it before the count below — otherwise `chars().count()` and the + // SQL CHECK would disagree on the key's length and identity. + if key.contains('\0') { + return Err(KvError::KeyContainsNul); + } + let code_points = key.chars().count(); + if code_points > MAX_KEY_LEN { + return Err(KvError::KeyTooLong { len: code_points }); } Ok(()) } @@ -192,4 +210,11 @@ mod tests { let k = "a".repeat(MAX_KEY_LEN); assert!(validate_key(&k).is_ok()); } + + #[test] + fn validate_rejects_embedded_nul() { + assert!(matches!(validate_key("a\0b"), Err(KvError::KeyContainsNul))); + // A leading/trailing NUL is rejected too. + assert!(matches!(validate_key("\0"), Err(KvError::KeyContainsNul))); + } } diff --git a/packages/rs-platform-wallet-storage/src/lib.rs b/packages/rs-platform-wallet-storage/src/lib.rs index b75ddee4658..21007f2edda 100644 --- a/packages/rs-platform-wallet-storage/src/lib.rs +++ b/packages/rs-platform-wallet-storage/src/lib.rs @@ -27,6 +27,20 @@ #![deny(rust_2018_idioms)] #![deny(unsafe_code)] +/// Shared 16 MiB ceiling for the two independent size caps in this crate: +/// the KV value cap ([`kv::MAX_VALUE_LEN`]) and the bincode-serde BLOB +/// decode cap (`sqlite::schema::blob::BLOB_SIZE_LIMIT_BYTES`). At the crate +/// root so the independently-compiled `kv` and `sqlite` features share one +/// source of truth. +pub const SIZE_LIMIT_BYTES: usize = 16 * 1024 * 1024; + +#[cfg(any(feature = "sqlite", feature = "secrets"))] +mod parent_permissions; +// Named by both error enums' ancestor-rejection variants, so it has to be +// reachable wherever either of them is. +#[cfg(any(feature = "sqlite", feature = "secrets"))] +pub use parent_permissions::InsecureAncestor; + #[cfg(feature = "kv")] pub mod kv; #[cfg(feature = "sqlite")] @@ -35,22 +49,20 @@ pub mod sqlite; #[cfg(feature = "secrets")] pub mod secrets; -// 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. +// Convenience re-exports so embedders can skip the `::sqlite::` segment +// for common names. #[cfg(feature = "kv")] pub use kv::{KvError, KvStore, ObjectId}; #[cfg(feature = "sqlite")] pub use sqlite::{ - default_auto_backup_dir, AutoBackupOperation, CommitReport, DeleteWalletReport, FlushMode, - JournalMode, PruneReport, RetentionPolicy, SqlitePersister, SqlitePersisterConfig, Synchronous, - WalletStorageError, + default_auto_backup_dir, prune_backups_in, AutoBackupOperation, CommitReport, + DeleteWalletReport, FlushMode, JournalMode, LoadCtx, LoadDegradation, LoadPolicy, LoadSite, + OwningAccount, PruneReport, RetentionPolicy, SqlitePersister, SqlitePersisterConfig, + 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. +// Compile-time assertions: `Send + Sync` and `PlatformWalletPersistence` +// object-safety. Gated to `sqlite` because they reference its types. #[cfg(feature = "sqlite")] #[allow(dead_code)] const fn _send_sync_check() {} @@ -67,9 +79,8 @@ fn _object_safety_check(persister: SqlitePersister) { std::sync::Arc::new(persister); } -// The keyring SPI must be object-safe and its error `Send + Sync`, so -// a backend can be held behind `Arc` and its errors crossed between threads / FFI. +// The keyring SPI must be object-safe with `Send + Sync` errors so a +// backend can live behind `Arc`. #[cfg(feature = "secrets")] #[allow(dead_code)] const fn _secrets_send_sync_check() {} diff --git a/packages/rs-platform-wallet-storage/src/parent_permissions.rs b/packages/rs-platform-wallet-storage/src/parent_permissions.rs new file mode 100644 index 00000000000..9359a21d6cb --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/parent_permissions.rs @@ -0,0 +1,177 @@ +//! Shared parent-directory permission check for file-backed stores. + +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub(crate) enum ParentPermissionsError { + Io(std::io::Error), + Insecure { + ancestor: PathBuf, + reason: InsecureAncestor, + }, +} + +/// Why an ancestor directory of a wallet file or vault was refused. +/// +/// The two causes need different remediations, and a mode alone cannot tell +/// them apart: an ancestor rejected for its OWNER usually carries a perfectly +/// ordinary `0755`, so reporting that mode and asking for `chmod go-w` sends +/// the user to a command that cannot work. +/// +/// Unix-only. `check_parent_perms` is a no-op on other targets — Windows ACL +/// inspection is tracked by dashpay/platform#3754 — so no value of this type is +/// ever produced there, and the POSIX remediation each arm names is always +/// correct where it can appear. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InsecureAncestor { + /// Group- or other-writable without the sticky bit: any local user can + /// replace entries in it, and so replace the `0600` file below it. + WritableWithoutSticky { + /// The offending POSIX mode bits. + mode: u32, + }, + /// Owned by neither the effective user nor a root identity: its owner can + /// grant itself write access whenever it likes, so the current mode is not + /// a guarantee of anything. + UntrustedOwner { + /// The ancestor's owner uid. + uid: u32, + /// The process's effective uid. + current_uid: u32, + }, +} + +/// One actionable sentence naming the exact ancestor and the exact command. +/// +/// `subject` is the artefact being protected ("database" / "vault"), so the two +/// error enums that carry this rejection phrase it identically. +pub(crate) fn insecure_ancestor_message( + subject: &str, + ancestor: &Path, + reason: &InsecureAncestor, +) -> String { + let path = ancestor.display(); + match reason { + InsecureAncestor::WritableWithoutSticky { mode } => format!( + "{subject} ancestor {path} is group/other-writable (mode {mode:04o}) and not sticky; \ + run `chmod go-w {path}` unless it is an intentional sticky shared directory" + ), + InsecureAncestor::UntrustedOwner { uid, current_uid } => format!( + "{subject} ancestor {path} is owned by uid {uid}, neither the current user \ + ({current_uid}) nor root; run `chown {current_uid} {path}`" + ), + } +} + +#[cfg(unix)] +fn trusted_owner(owner: u32, current_uid: u32, root_uid: u32) -> bool { + owner == current_uid || owner == 0 || owner == root_uid +} + +#[cfg(unix)] +#[expect(unsafe_code, reason = "libc geteuid requires an unsafe call")] +fn effective_uid() -> u32 { + // SAFETY: geteuid takes no arguments, has no failure mode, and reads only + // the process credential maintained by the kernel. + unsafe { libc::geteuid() } +} + +/// Validate Unix ownership and replacement permissions up to the filesystem root. +/// +/// Both the lexical path and its canonical target are walked through `/`, so a +/// symlink cannot hide unsafe target ancestors. A writable component is +/// accepted only with the sticky bit, and every component must be owned by the +/// effective user or a root identity. The filesystem root's owner is treated +/// as root for user-namespace environments where uid 0 is mapped to another uid. +/// +/// Both walks classify through here, so neither can report an ownership +/// rejection as a permission one. Write access is checked first: it is the +/// condition whose mode is worth printing. +#[cfg(unix)] +fn reject_ancestor( + ancestor: &Path, + uid: u32, + mode: u32, + writable_without_sticky: bool, + current_uid: u32, + root_uid: u32, +) -> Result<(), ParentPermissionsError> { + let reason = if writable_without_sticky { + InsecureAncestor::WritableWithoutSticky { mode } + } else if !trusted_owner(uid, current_uid, root_uid) { + InsecureAncestor::UntrustedOwner { uid, current_uid } + } else { + return Ok(()); + }; + Err(ParentPermissionsError::Insecure { + ancestor: ancestor.to_path_buf(), + reason, + }) +} + +#[cfg(unix)] +pub(crate) fn check_parent_perms(parent: &Path) -> Result<(), ParentPermissionsError> { + use std::os::unix::fs::MetadataExt; + + let current_uid = effective_uid(); + let root_uid = std::fs::metadata("/") + .map_err(ParentPermissionsError::Io)? + .uid(); + let absolute = if parent.is_absolute() { + parent.to_path_buf() + } else { + std::env::current_dir() + .map_err(ParentPermissionsError::Io)? + .join(parent) + }; + let canonical = std::fs::canonicalize(&absolute).map_err(ParentPermissionsError::Io)?; + + for ancestor in absolute.ancestors() { + let meta = std::fs::symlink_metadata(ancestor).map_err(ParentPermissionsError::Io)?; + let mode = meta.mode() & 0o7777; + let writable_without_sticky = + !meta.file_type().is_symlink() && mode & 0o022 != 0 && mode & 0o1000 == 0; + reject_ancestor( + ancestor, + meta.uid(), + mode, + writable_without_sticky, + current_uid, + root_uid, + )?; + } + for ancestor in canonical.ancestors() { + let meta = std::fs::metadata(ancestor).map_err(ParentPermissionsError::Io)?; + let mode = meta.mode() & 0o7777; + let writable_without_sticky = mode & 0o022 != 0 && mode & 0o1000 == 0; + reject_ancestor( + ancestor, + meta.uid(), + mode, + writable_without_sticky, + current_uid, + root_uid, + )?; + } + Ok(()) +} + +// Windows ACL checks require platform-specific security APIs; tracked by +// https://github.com/dashpay/platform/issues/3754. +#[cfg(not(unix))] +pub(crate) fn check_parent_perms(_parent: &Path) -> Result<(), ParentPermissionsError> { + Ok(()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::trusted_owner; + + #[test] + fn trusted_owner_accepts_current_and_root_identities_only() { + assert!(trusted_owner(1000, 1000, 65_534)); + assert!(trusted_owner(0, 1000, 65_534)); + assert!(trusted_owner(65_534, 1000, 65_534)); + assert!(!trusted_owner(2000, 1000, 65_534)); + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/error.rs b/packages/rs-platform-wallet-storage/src/secrets/error.rs index 506814cd49f..b48a8afb7f6 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/error.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/error.rs @@ -1,26 +1,17 @@ //! Secret-store error taxonomy and its `keyring_core::Error` projection. //! -//! One concrete `thiserror` enum shared by both -//! [`SecretStore`](crate::secrets::SecretStore) backends (the encrypted -//! file vault and the OS keyring), no `#[non_exhaustive]`, **no** secret -//! byte, passphrase, plaintext, or stringified source that could carry -//! one in any variant. `#[error]` strings are static + structural; only -//! non-secret diagnostics (POSIX mode bits, header version int, vault -//! path) are carried as typed fields (CWE-209/CWE-532). +//! Variants carry only non-secret diagnostics (POSIX mode bits, header +//! version, vault path) — never a secret byte, passphrase, or plaintext +//! (CWE-209/CWE-532). The single carried source is the [`Io`] variant's +//! OS error (an errno plus the non-secret caller-supplied path); every +//! other variant is source-free so a crypto/format failure can't stringify +//! a secret. The public, fully-typed path is the +//! [`SecretStore`](crate::secrets::SecretStore) API; the SPI projection into +//! `keyring_core::Error` is lossy (see the [`From`] impl). //! -//! The `EncryptedFileStore` surfaces this enum at its construction / -//! `rekey` API; its `keyring_core::api::CredentialApi` / -//! `CredentialStoreApi` impls project it into `keyring_core::Error` via -//! [`From`] so SPI callers see a uniform error. The `WrongPassphrase` / -//! `AlreadyLocked` variants box the typed `SecretStoreError` as the -//! `NoStorageAccess` source, so an SPI consumer can recover them -//! losslessly via `source().downcast_ref::()`; the -//! `BadStoreFormat` group has no box slot and carries only a secret-free -//! string. Either way, the fully typed path is the public -//! [`SecretStore`](crate::secrets::SecretStore) API, which returns -//! `SecretStoreError` directly. - -use std::path::Path; +//! [`Io`]: SecretStoreError::Io + +use std::path::{Path, PathBuf}; use keyring_core::Error as KeyringError; @@ -34,19 +25,87 @@ pub enum SecretStoreError { #[error("wrong passphrase")] WrongPassphrase, - /// AEAD tag failure on a stored entry (or a rekey re-encrypt) *after* - /// the header verify-token already passed: the entry ciphertext is - /// corrupt or tampered, **not** a wrong passphrase. Carries no - /// plaintext (CWE-347). + /// Tier-2 strip/downgrade guard: the caller asserted — by supplying + /// an object password — that this object MUST be password-protected, + /// but the stored value is a well-formed UNPROTECTED envelope + /// (scheme-0), i.e. a strip/downgrade. **Fails closed:** the stored + /// bytes are NEVER returned (CWE-757/CWE-345). + #[error("expected a password-protected secret but the stored value is unprotected")] + ExpectedProtectedButUnsealed, + + /// Tier-2: a valid password-protected (scheme-1) envelope was read + /// with NO object password supplied. Never returns ciphertext. + #[error("secret is password-protected; a password is required")] + NeedsPassword, + + /// Tier-2: the object password failed the envelope's AEAD tag. Carries + /// **no** plaintext and no source (CWE-347). Distinct from + /// [`WrongPassphrase`] (the Tier-1 vault passphrase). On the + /// [`SecretStore::Os`] arm a tag failure may also indicate keychain + /// corruption rather than a wrong password — documented in + /// `SECRETS.md`; one AEAD tag cannot disambiguate the two. + /// + /// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase + /// [`SecretStore::Os`]: crate::secrets::SecretStore::Os + #[error("wrong object password")] + WrongPassword, + + /// A vault passphrase (Tier-1 `open`/`rekey`) or an object password + /// (Tier-2 enrol/unwrap) was shorter than [`MIN_PASSPHRASE_LEN`] after + /// trimming. CWE-521. + /// + /// Neutral wording: the variant covers both Tier-1 vault passphrases and + /// Tier-2 per-object passwords; the caller's context determines which. + /// Tier-1 callers wanting a deliberately keyless vault should use + /// [`EncryptedFileStore::open_unprotected`](crate::secrets::EncryptedFileStore::open_unprotected). + /// + /// [`MIN_PASSPHRASE_LEN`]: crate::secrets::MIN_PASSPHRASE_LEN + #[error("passphrase or password is blank or too short")] + BlankPassphrase, + + /// A vault passphrase (Tier-1 `open`/`rekey`) or an object password + /// (Tier-2 enrol/unwrap) was longer than [`MAX_PASSPHRASE_LEN`]. + /// + /// Passphrases live in guarded, `mlock`ed pages for as long as the + /// store they unlock, and up to three are resident at once during a + /// re-protect, so an unbounded one would blow the crate's + /// locked-memory budget (documented at + /// [`MAX_SECRET_LEN`](crate::secrets::MAX_SECRET_LEN)). The ceiling is + /// far above any human-typed passphrase; only a programmatic or + /// config-supplied value realistically reaches it. Carries lengths + /// only, never any part of the value (CWE-209). + /// + /// [`MAX_PASSPHRASE_LEN`]: crate::secrets::MAX_PASSPHRASE_LEN + #[error("passphrase exceeds maximum length of {max} bytes (got {found})")] + PassphraseTooLong { + /// Length of the offending passphrase, in bytes. + found: usize, + /// The enforced ceiling, in bytes. + max: usize, + }, + + /// AEAD tag failure on a stored entry (or rekey re-encrypt) *after* + /// the header verify-token passed: the entry ciphertext is corrupt or + /// tampered, **not** a wrong passphrase. No plaintext (CWE-347). #[error("vault entry failed integrity check (corruption or tampering)")] Corruption, - /// Argon2 key derivation failed. The upstream error carries no - /// useful non-secret diagnostic, so it is intentionally not - /// embedded. + /// Argon2 key derivation failed. The upstream error carries no useful + /// non-secret diagnostic, so it is not embedded. #[error("key derivation failed")] KdfFailure, + /// The OS CSPRNG (`getrandom`) could not supply entropy for a salt, + /// nonce, or key draw. The upstream error carries no useful non-secret + /// diagnostic, so it is not embedded. Kept distinct from + /// [`KdfFailure`] so an exhausted/blocked entropy source is not + /// misdiagnosed as an Argon2 parameter problem — the CSPRNG backs the + /// nonce and salt draws too, not just key derivation. + /// + /// [`KdfFailure`]: SecretStoreError::KdfFailure + #[error("system entropy source unavailable")] + EntropyUnavailable, + /// The vault header declared a `format_version` this build does not /// understand. #[error("unsupported vault format version {found}")] @@ -55,6 +114,21 @@ pub enum SecretStoreError { found: u32, }, + /// A Tier-2 secret envelope decoded with a `version` this build does + /// not understand. Fails closed REGARDLESS of the password argument + /// — an unparseable future format can be neither safely unwrapped + /// nor safely treated as unprotected, so it is refused both ways. + /// Mirrors [`VersionUnsupported`] for the vault format. + /// + /// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported + #[error("unsupported secret envelope version {found}")] + UnsupportedEnvelopeVersion { + /// The full `version` field read from the (unauthenticated) + /// envelope header. `u32` to match `Envelope.version` — a truncating + /// `u8` would alias distinct out-of-range versions in diagnostics. + found: u32, + }, + /// The vault file was malformed (bad magic, truncated header, bad /// record framing) — no plaintext was produced. #[error("malformed vault file")] @@ -62,24 +136,109 @@ pub enum SecretStoreError { /// `label` failed the `^[A-Za-z0-9._-]{1,64}$` allowlist /// (CWE-22/CWE-20). - #[error("invalid label")] + #[error("invalid secret label; expected ^[A-Za-z0-9._-]{{1,64}}$")] InvalidLabel, + /// No credential exists under `(service, label)` on either arm. Returned + /// by mutators that need an entry to operate on (e.g. [`reprotect`]) so + /// absence is a signal, not a silent no-op — caller's protection-status + /// record disagreeing with the backend must not be swallowed. Surfaced + /// by the file arm when `delete_bytes` reports `Ok(false)` and by the + /// OS arm when [`keyring_core::Error::NoEntry`] bubbles out. + /// + /// [`reprotect`]: crate::secrets::SecretStore::reprotect + #[error("secret was not found")] + NoEntry, + + /// The host's memory pages are larger than the crate's locked-memory + /// budget assumes, so no store can honour that budget here (CWE-316). + /// + /// `memsec` rounds every guarded allocation up to the page size it + /// reads from the kernel at run time, while the budget documented at + /// [`MAX_SECRET_LEN`](crate::secrets::MAX_SECRET_LEN) is denominated + /// in 16 KiB pages. On a larger-paged host the real peak exceeds the + /// budgeted one by the ratio between the two sizes; `mlock` then + /// fails open with a warning and seed / xpriv material silently + /// becomes swappable. Construction refuses instead of degrading. + /// + /// Reserved for exotic hosts — 64 KiB-page aarch64 RHEL/SLES builds. + /// 4 KiB Linux and 16 KiB Apple Silicon / iOS both pass. + /// + /// Smaller-than-assumed pages are accepted: they turn every + /// `locked_cost` figure into an over-estimate, which leaves the budget + /// conservative rather than overrun. + #[error( + "host memory pages are {found} bytes but locked secret memory is budgeted for {assumed}; \ + secret pages would exceed RLIMIT_MEMLOCK and silently become swappable — \ + run this process on a host with {assumed}-byte memory pages" + )] + HostPageSizeExceedsBudget { + /// The page size this host reported (not secret). + found: usize, + /// The page size the compiled-in budget assumes (not secret). + assumed: usize, + }, + /// A pre-existing vault file had permissions looser than `0600`. /// Refuse rather than tighten-and-trust. - #[error("vault file has insecure permissions")] + #[error( + "vault file at {path} has mode {mode:04o}; it must be 0600 — run `chmod 600 {path}`", + path = .path.display() + )] InsecurePermissions { + /// The vault path (not secret). + path: PathBuf, /// The offending POSIX mode bits (not secret). mode: u32, }, - /// The vault sidecar (`.lock`) is already held by - /// another `EncryptedFileStore` handle — in this process or in - /// another process. The resident-vault model requires exclusive - /// ownership of the vault file for the store's lifetime, so the - /// second `open()` fails fast (no retry, no wait budget). Drop the - /// other handle, or wait for the other process to exit, and retry. - /// A recoverable runtime state, not a logic bug. + /// A pre-existing vault file is owned by a user other than the process's + /// effective user. Refuse rather than trust a file another user controls. + #[error( + "vault file at {path} is owned by another user (uid {found}); change its owner to the current uid {expected}", + path = .path.display() + )] + InsecureOwnership { + /// The vault path (not secret). + path: PathBuf, + /// The file owner's uid. + found: u32, + /// The process's effective uid. + expected: u32, + }, + + /// A vault ancestor was writable without the sticky bit or owned by + /// neither the current user nor root. Either condition can allow another + /// local user to replace the vault despite its own `0600` mode. + /// + /// Names the offending ancestor, not the vault's own parent: the walk runs + /// to `/`, and telling a user that one of nine components is at fault is + /// not a remediation. + #[error("{}", crate::parent_permissions::insecure_ancestor_message("vault", .ancestor, .reason))] + InsecureParentDir { + /// The ancestor that was refused (not secret). + ancestor: PathBuf, + /// Which of the two conditions fired; they need different remediations. + reason: crate::parent_permissions::InsecureAncestor, + }, + + /// A secret offered for storage exceeded the per-secret write cap + /// ([`MAX_SECRET_LEN`](crate::secrets::MAX_SECRET_LEN)). Rejected at + /// the write boundary so an oversized entry never inflates the shared + /// vault past the read-side ceiling and bricks every wallet on reopen. + #[error("secret exceeds maximum size of {max} bytes (got {found})")] + SecretTooLarge { + /// The offered secret length (bytes). + found: usize, + /// The compiled-in per-secret ceiling (bytes). + max: usize, + }, + + /// The vault sidecar (`.lock`) is already held by another + /// `EncryptedFileStore` handle in this or another process. The + /// resident-vault model needs exclusive ownership for the store's + /// lifetime, so a second `open()` fails fast (no retry). Recoverable: + /// drop the other handle and retry. #[error("vault is already locked by another store handle")] AlreadyLocked, @@ -95,28 +254,35 @@ pub enum SecretStoreError { max: u64, }, - /// Internal AEAD tag failure with no vault context yet attached. The - /// crypto seam (`crypto::open`) cannot tell *why* a tag failed, so it - /// returns this; callers translate it to [`WrongPassphrase`] (in the - /// verify-token context) or [`Corruption`] (in an entry context). - /// Never escapes to the SPI / public surface. + /// Internal AEAD tag failure with no vault context attached: + /// `crypto::open` cannot tell *why* a tag failed, so callers translate + /// this to [`WrongPassphrase`] (verify-token context) or + /// [`Corruption`] (entry context). Never escapes to the SPI surface. /// /// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase /// [`Corruption`]: SecretStoreError::Corruption #[error("decryption/integrity check failed")] Decrypt, + /// AEAD encrypt-side failure (cipher construction or `encrypt`). + /// Effectively unreachable — the key is always 32 bytes and plaintext + /// never approaches XChaCha20's ~256 GiB limit — but kept typed so a + /// write failure is never mislabeled a [`KdfFailure`]. + /// + /// [`KdfFailure`]: SecretStoreError::KdfFailure + #[error("encryption failed")] + Encrypt, + /// Filesystem error (open / write / rename / fsync). The inner - /// [`IoError`] carries an OS code and, when the failing operation - /// knew it, the *non-secret* path it was operating on — a - /// caller-supplied filesystem path, never a secret byte. + /// [`IoError`] carries an OS code and, when known, the *non-secret* + /// caller-supplied path — never a secret byte. #[error("{0}")] Io(#[from] IoError), - /// An OS-keyring backend (the [`SecretStore::Os`] arm) failure, - /// projected to a non-secret discriminant. Keyring variants that - /// carry raw bytes (`BadEncoding`, `BadDataFormat`) are collapsed to - /// [`OsKeyringErrorKind::BadStoreFormat`] — their bytes never enter + /// An OS-keyring backend ([`SecretStore::Os`] arm) failure, projected + /// to a non-secret discriminant. Byte-bearing keyring variants + /// (`BadEncoding`, `BadDataFormat`) collapse to + /// [`OsKeyringErrorKind::BadStoreFormat`]; their bytes never enter /// this type (CWE-209/CWE-532). /// /// [`SecretStore::Os`]: crate::secrets::SecretStore::Os @@ -128,28 +294,100 @@ pub enum SecretStoreError { } impl SecretStoreError { - /// Build an [`Io`](SecretStoreError::Io) error that names the - /// non-secret filesystem `path` the failing operation touched. - /// Use at the vault read / write / lock seams where the path is - /// known; the bare `?`/`From` conversion (path - /// unknown) stays available for the deep helpers. + /// Build an [`Io`](SecretStoreError::Io) error naming the non-secret + /// `path` the failing operation touched. Use at the read/write/lock + /// seams; deep helpers can still use the bare `?` (path unknown). pub(crate) fn io_at(path: &Path, source: std::io::Error) -> Self { Self::Io(IoError { path: Some(path.to_path_buf()), source, }) } + + /// `true` when the failure clears on a retry after the caller acts on + /// it. Mirrors `WalletStorageError::is_transient` on this crate's + /// SQLite arm so the two typed errors read as one family. + /// + /// Only [`AlreadyLocked`](Self::AlreadyLocked) qualifies: drop the + /// other store handle and re-`open`. Every other variant is a + /// wrong-credential, malformed-input, crypto, permission, size, or I/O + /// failure a bare retry cannot fix (a failing CSPRNG or disk may + /// recover, but not through this store's own retry contract). + /// + /// The match is wildcard-free so a new variant forces an explicit + /// classification here. + pub fn is_recoverable(&self) -> bool { + match self { + Self::AlreadyLocked => true, + Self::WrongPassphrase + | Self::ExpectedProtectedButUnsealed + | Self::NeedsPassword + | Self::WrongPassword + | Self::BlankPassphrase + | Self::PassphraseTooLong { .. } + | Self::Corruption + | Self::KdfFailure + | Self::EntropyUnavailable + | Self::VersionUnsupported { .. } + | Self::UnsupportedEnvelopeVersion { .. } + | Self::MalformedVault + | Self::InvalidLabel + | Self::NoEntry + | Self::HostPageSizeExceedsBudget { .. } + | Self::InsecurePermissions { .. } + | Self::InsecureOwnership { .. } + | Self::InsecureParentDir { .. } + | Self::SecretTooLarge { .. } + | Self::VaultTooLarge { .. } + | Self::Decrypt + | Self::Encrypt + | Self::Io(_) + | Self::OsKeyring { .. } => false, + } + } + + /// Short, lowercase, snake_case tag per variant for tracing fields — + /// stable and greppable, mirroring `WalletStorageError::error_kind_str` + /// on this crate's SQLite arm. Match on this, never on the + /// human-facing `Display`/`Debug` text (documented unstable). + pub fn error_kind_str(&self) -> &'static str { + match self { + Self::WrongPassphrase => "wrong_passphrase", + Self::ExpectedProtectedButUnsealed => "expected_protected_but_unsealed", + Self::NeedsPassword => "needs_password", + Self::WrongPassword => "wrong_password", + Self::BlankPassphrase => "blank_passphrase", + Self::PassphraseTooLong { .. } => "passphrase_too_long", + Self::Corruption => "corruption", + Self::KdfFailure => "kdf_failure", + Self::EntropyUnavailable => "entropy_unavailable", + Self::VersionUnsupported { .. } => "version_unsupported", + Self::UnsupportedEnvelopeVersion { .. } => "unsupported_envelope_version", + Self::MalformedVault => "malformed_vault", + Self::InvalidLabel => "invalid_label", + Self::NoEntry => "no_entry", + Self::HostPageSizeExceedsBudget { .. } => "host_page_size_exceeds_budget", + Self::InsecurePermissions { .. } => "insecure_permissions", + Self::InsecureOwnership { .. } => "insecure_ownership", + Self::InsecureParentDir { .. } => "insecure_parent_dir", + Self::SecretTooLarge { .. } => "secret_too_large", + Self::AlreadyLocked => "already_locked", + Self::VaultTooLarge { .. } => "vault_too_large", + Self::Decrypt => "decrypt", + Self::Encrypt => "encrypt", + Self::Io(_) => "io", + Self::OsKeyring { .. } => "os_keyring", + } + } } /// Filesystem-error payload for [`SecretStoreError::Io`]. Wraps the OS -/// [`std::io::Error`] and, when the failing operation knew it, the -/// non-secret path it was operating on. `From` is -/// derived so a bare `?` still works (path defaults to `None`); the -/// path-aware seams attach it via [`SecretStoreError::io_at`]. +/// [`std::io::Error`] plus the non-secret path, when known. A bare `?` +/// works (path `None`); path-aware seams use [`SecretStoreError::io_at`]. #[derive(Debug, thiserror::Error)] pub struct IoError { - /// The non-secret filesystem path, when the failing operation knew - /// it. A caller-supplied path, never a secret. + /// The non-secret caller-supplied path, when the failing operation + /// knew it. pub path: Option, /// The underlying OS error. pub source: std::io::Error, @@ -171,14 +409,12 @@ impl From for IoError { } /// Non-secret discriminant for an OS-keyring backend failure, projected -/// from `keyring_core::Error` for the [`SecretStore::Os`] arm. Carries no -/// payload, so no secret byte, path, or attribute value can ride along. +/// from `keyring_core::Error` for the [`SecretStore::Os`] arm. Payload- +/// less, so no secret byte / path / attribute value can ride along. /// /// [`SecretStore::Os`]: crate::secrets::SecretStore::Os #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OsKeyringErrorKind { - /// `keyring_core::Error::NoEntry`. - NoEntry, /// `keyring_core::Error::NoStorageAccess` (store locked / inaccessible). NoStorageAccess, /// `keyring_core::Error::NoDefaultStore` (no reachable backend). @@ -194,7 +430,6 @@ pub enum OsKeyringErrorKind { impl std::fmt::Display for OsKeyringErrorKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { - Self::NoEntry => "no entry", Self::NoStorageAccess => "storage inaccessible", Self::NoDefaultStore => "no default store", Self::BadStoreFormat => "bad store format", @@ -210,60 +445,77 @@ impl From for SecretStoreError { } } -/// Bare `?` on a [`std::io::Error`] inside a function returning -/// [`SecretStoreError`] threads through [`IoError`] (path `None`); the -/// path-aware seams call [`SecretStoreError::io_at`] instead. +/// Bare `?` on an [`std::io::Error`] threads through [`IoError`] with +/// path `None`; path-aware seams call [`SecretStoreError::io_at`]. impl From for SecretStoreError { fn from(source: std::io::Error) -> Self { Self::Io(IoError::from(source)) } } -/// Project a [`SecretStoreError`] into `keyring_core::Error` for the -/// `CredentialApi` / `CredentialStoreApi` SPI seam. +/// Project a [`SecretStoreError`] into `keyring_core::Error` for the SPI +/// seam. Lossy by design — the lossless typed path is the +/// [`SecretStore`](crate::secrets::SecretStore) API. /// -/// - [`WrongPassphrase`] and [`AlreadyLocked`] ride in -/// [`KeyringError::NoStorageAccess`] (operator UX: "ask the operator to -/// unlock / retry") with the typed `SecretStoreError` boxed as the -/// source, so an SPI consumer can losslessly recover the variant via +/// - [`WrongPassphrase`] / [`AlreadyLocked`] and the Tier-2 credential / +/// protection states ([`NeedsPassword`], [`WrongPassword`], +/// [`ExpectedProtectedButUnsealed`], [`BlankPassphrase`]) ride in +/// [`KeyringError::NoStorageAccess`] with the typed error boxed as the +/// source, recoverable via /// `err.source().and_then(|s| s.downcast_ref::())`. -/// - [`Corruption`], [`KdfFailure`], [`VersionUnsupported`], -/// [`MalformedVault`], [`InsecurePermissions`], the internal -/// [`Decrypt`], and [`OsKeyring`] collapse into -/// [`KeyringError::BadStoreFormat`], whose `String` payload has no box -/// slot, so they carry only a static secret-free string (never secret -/// data in a format error). They remain losslessly typed on the -/// [`SecretStore`](crate::secrets::SecretStore) path. -/// - [`InvalidLabel`] becomes `KeyringError::Invalid("user", _)`. -/// - [`Io`] becomes [`KeyringError::PlatformFailure`]. +/// These are all "the caller must act on a credential/expectation to +/// proceed" states, so lossless recovery lets an SPI consumer react +/// precisely. +/// - The format/crypto group — including [`UnsupportedEnvelopeVersion`] +/// (a fail-closed forward-format incompatibility, mirroring +/// [`VersionUnsupported`]) — collapses into +/// [`KeyringError::BadStoreFormat`] (a static secret-free string — that +/// variant has no box slot). +/// - [`InvalidLabel`] → `KeyringError::Invalid("user", _)`; +/// [`Io`] and [`HostPageSizeExceedsBudget`] (a host the crate cannot run +/// on, not a store-format problem) → [`KeyringError::PlatformFailure`]. /// /// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase /// [`AlreadyLocked`]: SecretStoreError::AlreadyLocked -/// [`Corruption`]: SecretStoreError::Corruption -/// [`KdfFailure`]: SecretStoreError::KdfFailure +/// [`NeedsPassword`]: SecretStoreError::NeedsPassword +/// [`WrongPassword`]: SecretStoreError::WrongPassword +/// [`ExpectedProtectedButUnsealed`]: SecretStoreError::ExpectedProtectedButUnsealed +/// [`BlankPassphrase`]: SecretStoreError::BlankPassphrase +/// [`UnsupportedEnvelopeVersion`]: SecretStoreError::UnsupportedEnvelopeVersion /// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported -/// [`MalformedVault`]: SecretStoreError::MalformedVault -/// [`InsecurePermissions`]: SecretStoreError::InsecurePermissions -/// [`Decrypt`]: SecretStoreError::Decrypt -/// [`OsKeyring`]: SecretStoreError::OsKeyring /// [`InvalidLabel`]: SecretStoreError::InvalidLabel /// [`Io`]: SecretStoreError::Io +/// [`HostPageSizeExceedsBudget`]: SecretStoreError::HostPageSizeExceedsBudget impl From for KeyringError { fn from(e: SecretStoreError) -> Self { use SecretStoreError as E; match e { - E::WrongPassphrase | E::AlreadyLocked => KeyringError::NoStorageAccess(Box::new(e)), + E::WrongPassphrase + | E::AlreadyLocked + | E::NeedsPassword + | E::WrongPassword + | E::ExpectedProtectedButUnsealed + | E::BlankPassphrase => KeyringError::NoStorageAccess(Box::new(e)), E::Corruption | E::KdfFailure + | E::EntropyUnavailable | E::VersionUnsupported { .. } + | E::UnsupportedEnvelopeVersion { .. } | E::MalformedVault | E::InsecurePermissions { .. } + | E::InsecureOwnership { .. } + | E::InsecureParentDir { .. } + | E::SecretTooLarge { .. } + | E::PassphraseTooLong { .. } | E::VaultTooLarge { .. } | E::Decrypt + | E::Encrypt | E::OsKeyring { .. } => KeyringError::BadStoreFormat(e.to_string()), E::InvalidLabel => { KeyringError::Invalid("user".to_string(), "label allowlist violation".to_string()) } + E::NoEntry => KeyringError::NoEntry, + E::HostPageSizeExceedsBudget { .. } => KeyringError::PlatformFailure(Box::new(e)), E::Io(io) => KeyringError::PlatformFailure(Box::new(io.source)), } } @@ -289,10 +541,33 @@ mod tests { for e in [ SecretStoreError::Corruption, SecretStoreError::Decrypt, + SecretStoreError::Encrypt, SecretStoreError::KdfFailure, SecretStoreError::VersionUnsupported { found: 999 }, SecretStoreError::MalformedVault, - SecretStoreError::InsecurePermissions { mode: 0o644 }, + SecretStoreError::InsecurePermissions { + path: "/vault".into(), + mode: 0o644, + }, + SecretStoreError::InsecureOwnership { + path: "/vault".into(), + found: 1001, + expected: 1000, + }, + SecretStoreError::InsecureParentDir { + ancestor: "/parent".into(), + reason: crate::parent_permissions::InsecureAncestor::WritableWithoutSticky { + mode: 0o777, + }, + }, + SecretStoreError::SecretTooLarge { + found: 100, + max: 10, + }, + SecretStoreError::VaultTooLarge { + found: 100, + max: 10, + }, ] { let k: KeyringError = e.into(); assert!(matches!(k, KeyringError::BadStoreFormat(_))); @@ -316,9 +591,6 @@ mod tests { #[test] fn io_at_names_path_in_display_without_leaking_secret() { - // The path-aware Io error renders the offending path so operators - // can see which file failed; the source message rides along, but - // no secret byte does (the path is caller-supplied). let err = SecretStoreError::io_at( std::path::Path::new("/var/lib/wallet/vault.pwsvault"), std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), @@ -340,6 +612,54 @@ mod tests { assert!(io.path.is_none()); } + #[test] + fn validation_and_permission_errors_are_actionable() { + let file = SecretStoreError::InsecurePermissions { + path: "/vault".into(), + mode: 0o644, + } + .to_string(); + assert!(file.contains("0644")); + assert!(file.contains("chmod 600")); + + let parent = SecretStoreError::InsecureParentDir { + ancestor: "/parent".into(), + reason: crate::parent_permissions::InsecureAncestor::WritableWithoutSticky { + mode: 0o777, + }, + } + .to_string(); + // The offender's own path, not the vault's parent: without it the user + // is told a chain has a bad link and left to find which. + assert!(parent.contains("/parent")); + assert!(parent.contains("0777")); + assert!(parent.contains("chmod go-w /parent")); + + // An ownership rejection must NOT hand out `chmod`: the mode is very + // likely unremarkable and changing it would not help. + let owned = SecretStoreError::InsecureParentDir { + ancestor: "/parent".into(), + reason: crate::parent_permissions::InsecureAncestor::UntrustedOwner { + uid: 999, + current_uid: 1000, + }, + } + .to_string(); + assert!(owned.contains("chown 1000 /parent")); + assert!( + !owned.contains("chmod"), + "an ownership rejection must not suggest chmod: {owned}" + ); + + assert!(SecretStoreError::InvalidLabel + .to_string() + .contains("A-Za-z0-9._-")); + assert_eq!( + SecretStoreError::NoEntry.to_string(), + "secret was not found" + ); + } + #[test] fn projection_carries_no_secret_in_display() { // Corruption / wrong-passphrase render static text only. @@ -351,9 +671,6 @@ mod tests { #[test] fn wrong_passphrase_is_recoverable_from_no_storage_access_source() { - // WrongPassphrase / AlreadyLocked box the typed SecretStoreError - // as the NoStorageAccess source, so an SPI consumer recovers the - // variant losslessly via `source().downcast_ref::()`. use std::error::Error as _; for original in [ SecretStoreError::WrongPassphrase, @@ -382,6 +699,102 @@ mod tests { assert!(!format!("{k}").contains("plaintext")); } + /// The five new variants exist, are constructable, render + /// distinct non-empty messages, and the Tier-2 `WrongPassword` is NOT + /// the Tier-1 `WrongPassphrase` (nor is the unseal error `Corruption`). + #[test] + fn new_variants_exist_and_are_distinct() { + use SecretStoreError as E; + assert_ne!(E::WrongPassword.to_string(), E::WrongPassphrase.to_string()); + assert_ne!( + E::ExpectedProtectedButUnsealed.to_string(), + E::Corruption.to_string() + ); + let msgs: std::collections::HashSet = [ + E::NeedsPassword.to_string(), + E::WrongPassword.to_string(), + E::BlankPassphrase.to_string(), + E::ExpectedProtectedButUnsealed.to_string(), + E::UnsupportedEnvelopeVersion { found: 2 }.to_string(), + ] + .into_iter() + .collect(); + assert_eq!(msgs.len(), 5, "all five messages must be distinct"); + } + + /// Display + Debug render static, secret-free text. The + /// version variant surfaces the (non-secret) version byte and nothing + /// more. + #[test] + fn new_variants_carry_no_secret_in_display() { + use SecretStoreError as E; + assert_eq!( + E::NeedsPassword.to_string(), + "secret is password-protected; a password is required" + ); + assert_eq!(E::WrongPassword.to_string(), "wrong object password"); + assert_eq!( + E::BlankPassphrase.to_string(), + "passphrase or password is blank or too short" + ); + assert_eq!( + E::ExpectedProtectedButUnsealed.to_string(), + "expected a password-protected secret but the stored value is unprotected" + ); + assert_eq!( + E::UnsupportedEnvelopeVersion { found: 7 }.to_string(), + "unsupported secret envelope version 7" + ); + // Debug is non-empty and free of plaintext-ish tokens for all. + for e in [ + E::NeedsPassword, + E::WrongPassword, + E::BlankPassphrase, + E::ExpectedProtectedButUnsealed, + E::UnsupportedEnvelopeVersion { found: 7 }, + ] { + let rendered = format!("{e} {e:?}"); + assert!(!rendered.contains("plaintext")); + } + } + + /// The four Tier-2 credential / + /// protection states project to a recoverable `NoStorageAccess` with + /// the typed error losslessly downcast-able, leaking no secret. + #[test] + fn tier2_state_errors_project_to_recoverable_no_storage_access() { + for original in [ + SecretStoreError::NeedsPassword, + SecretStoreError::WrongPassword, + SecretStoreError::ExpectedProtectedButUnsealed, + SecretStoreError::BlankPassphrase, + ] { + let want = original.to_string(); + let k: KeyringError = original.into(); + assert!(!format!("{k}").contains("plaintext")); + match &k { + KeyringError::NoStorageAccess(src) => { + let recovered = src.downcast_ref::(); + assert!( + matches!(recovered, Some(e) if e.to_string() == want), + "expected recoverable {want}, got {recovered:?}" + ); + } + other => panic!("expected NoStorageAccess for {want}, got {other:?}"), + } + } + } + + /// `UnsupportedEnvelopeVersion` projects to the + /// secret-free `BadStoreFormat` group (forward-format incompat, + /// mirroring `VersionUnsupported`). + #[test] + fn unsupported_envelope_version_projects_to_bad_store_format() { + let k: KeyringError = SecretStoreError::UnsupportedEnvelopeVersion { found: 9 }.into(); + assert!(matches!(k, KeyringError::BadStoreFormat(_))); + assert!(!format!("{k}").contains("plaintext")); + } + #[test] fn os_keyring_projects_to_bad_store_format() { let k: KeyringError = SecretStoreError::OsKeyring { @@ -390,4 +803,116 @@ mod tests { .into(); assert!(matches!(k, KeyringError::BadStoreFormat(_))); } + + /// `EntropyUnavailable` is a distinct, secret-free CSPRNG-failure + /// variant — NOT aliased to `KdfFailure` — and projects to the + /// secret-free `BadStoreFormat` group like the rest of the crypto family. + #[test] + fn entropy_unavailable_is_distinct_and_secret_free() { + use SecretStoreError as E; + assert_ne!( + E::EntropyUnavailable.to_string(), + E::KdfFailure.to_string(), + "entropy failure must not read as a key-derivation failure" + ); + assert_eq!( + E::EntropyUnavailable.to_string(), + "system entropy source unavailable" + ); + let k: KeyringError = E::EntropyUnavailable.into(); + assert!(matches!(k, KeyringError::BadStoreFormat(_))); + assert!(!format!("{k}").contains("plaintext")); + } + + /// `AlreadyLocked` is the only recoverable-by-retry variant (drop the + /// other handle and re-`open`); a representative spread of the rest is + /// non-recoverable. + #[test] + fn only_already_locked_is_recoverable() { + use SecretStoreError as E; + assert!(E::AlreadyLocked.is_recoverable()); + for e in [ + E::WrongPassphrase, + E::Corruption, + E::KdfFailure, + E::EntropyUnavailable, + E::MalformedVault, + E::InvalidLabel, + E::NoEntry, + E::Decrypt, + E::Encrypt, + E::from(std::io::Error::other("boom")), + E::OsKeyring { + kind: OsKeyringErrorKind::Backend, + }, + ] { + assert!( + !e.is_recoverable(), + "{e} must not be classified recoverable" + ); + } + } + + /// `error_kind_str` returns a stable snake_case tag; the sampled tags + /// are pinned and the full variant set produces no duplicate tag. + #[test] + fn error_kind_str_tags_are_stable_and_unique() { + use SecretStoreError as E; + assert_eq!(E::AlreadyLocked.error_kind_str(), "already_locked"); + assert_eq!(E::WrongPassphrase.error_kind_str(), "wrong_passphrase"); + assert_eq!( + E::EntropyUnavailable.error_kind_str(), + "entropy_unavailable" + ); + assert_eq!( + E::Io(std::io::Error::other("x").into()).error_kind_str(), + "io" + ); + + let tags: Vec<&str> = [ + E::WrongPassphrase, + E::ExpectedProtectedButUnsealed, + E::NeedsPassword, + E::WrongPassword, + E::BlankPassphrase, + E::Corruption, + E::KdfFailure, + E::EntropyUnavailable, + E::VersionUnsupported { found: 1 }, + E::UnsupportedEnvelopeVersion { found: 1 }, + E::MalformedVault, + E::InvalidLabel, + E::NoEntry, + E::InsecurePermissions { + path: "/vault".into(), + mode: 0, + }, + E::InsecureOwnership { + path: "/vault".into(), + found: 1, + expected: 2, + }, + E::InsecureParentDir { + ancestor: "/parent".into(), + reason: crate::parent_permissions::InsecureAncestor::UntrustedOwner { + uid: 0, + current_uid: 1, + }, + }, + E::SecretTooLarge { found: 1, max: 0 }, + E::AlreadyLocked, + E::VaultTooLarge { found: 1, max: 0 }, + E::Decrypt, + E::Encrypt, + E::from(std::io::Error::other("x")), + E::OsKeyring { + kind: OsKeyringErrorKind::Backend, + }, + ] + .iter() + .map(SecretStoreError::error_kind_str) + .collect(); + let unique: std::collections::HashSet<&str> = tags.iter().copied().collect(); + assert_eq!(unique.len(), tags.len(), "every variant needs a unique tag"); + } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs b/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs index 3205db672f5..43c3eb0cfd5 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs @@ -2,11 +2,12 @@ //! //! `pub(crate)` only — no crypto primitive escapes the `secrets` tree. -use argon2::{Algorithm, Argon2, Params, Version}; +use argon2::{Algorithm, Argon2, Block, Params, Version}; use chacha20poly1305::aead::Aead; use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; use getrandom::getrandom; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; use super::super::secret::{SecretBytes, SecretString}; use super::format::KDF_ID_ARGON2ID; @@ -19,11 +20,10 @@ pub(crate) const ARGON2_MIN_T: u32 = 2; pub(crate) const ARGON2_P: u32 = 1; /// Argon2 parameter ceilings. Vault `kdf` params are attacker- -/// controllable JSON, so an oversized `m_kib`/`t` would let a crafted -/// vault force a multi-GiB allocation or an unbounded-time derivation (a -/// DoS) before any tag check. 1 GiB memory and 16 passes bound the cost -/// well above the shipped default (64 MiB, t=3) yet far below an -/// exhaustion threshold. +/// controllable JSON, so without a cap an oversized `m_kib`/`t` could +/// force a multi-GiB allocation or unbounded derivation (DoS) before any +/// tag check. 1 GiB / 16 passes is well above the default, far below +/// exhaustion. pub(crate) const ARGON2_MAX_M_KIB: u32 = 1_048_576; pub(crate) const ARGON2_MAX_T: u32 = 16; @@ -31,6 +31,35 @@ pub(crate) const ARGON2_MAX_T: u32 = 16; pub(crate) const ARGON2_DEFAULT_M_KIB: u32 = 65_536; pub(crate) const ARGON2_DEFAULT_T: u32 = 3; +/// Tier-2 envelope per-read ceiling — the strongest header +/// [`KdfParams::enforce_read_ceiling`] will derive under. **Wire-format +/// constants, not tunables**: a protected secret whose header this build +/// refuses is unrecoverable, so these may only ever be RAISED. The +/// `ARGON2_DEFAULT_*` write target is an ordinary tunable and must stay +/// at or below them. +pub(crate) const ARGON2_READ_MAX_M_KIB: u32 = 65_536; +pub(crate) const ARGON2_READ_MAX_T: u32 = 3; + +/// The read ceiling must contain the write target, and both must sit +/// inside the [`KdfParams::enforce_bounds`] band. Violating the first +/// bricks reads in one of two directions, so it breaks the BUILD: unlike +/// a runtime guard there is no legitimate configuration in which it +/// fails. If this fires, RAISE the read ceiling — never delete it. +const _: () = { + assert!( + ARGON2_DEFAULT_M_KIB <= ARGON2_READ_MAX_M_KIB && ARGON2_DEFAULT_T <= ARGON2_READ_MAX_T, + "Argon2 write target exceeds the Tier-2 read ceiling: raise ARGON2_READ_MAX_* to match, \ + or every freshly written envelope is refused by the build that wrote it" + ); + assert!( + ARGON2_READ_MAX_M_KIB >= ARGON2_MIN_M_KIB + && ARGON2_READ_MAX_M_KIB <= ARGON2_MAX_M_KIB + && ARGON2_READ_MAX_T >= ARGON2_MIN_T + && ARGON2_READ_MAX_T <= ARGON2_MAX_T, + "the Tier-2 read ceiling must sit inside the enforce_bounds band" + ); +}; + /// CSPRNG salt width (≥16 required; we use 32). pub(crate) const SALT_LEN: usize = 32; /// XChaCha20-Poly1305 nonce width. @@ -38,16 +67,18 @@ pub(crate) const NONCE_LEN: usize = 24; /// Derived AEAD key width. pub(crate) const KEY_LEN: usize = 32; -/// Fill `buf` with CSPRNG bytes (`OsRng` via `getrandom`). +/// Fill `buf` with CSPRNG bytes (`OsRng` via `getrandom`). Backs the salt, +/// nonce, and key-material draws, so a failure is reported as +/// [`SecretStoreError::EntropyUnavailable`] — never `KdfFailure`, which +/// would misname the failing subsystem on the nonce/salt paths. pub(crate) fn random_bytes(buf: &mut [u8]) -> Result<(), SecretStoreError> { - getrandom(buf).map_err(|_| SecretStoreError::KdfFailure) + getrandom(buf).map_err(|_| SecretStoreError::EntropyUnavailable) } -/// Argon2id parameters as stored in / read from the vault. Serializes -/// directly to the on-disk `kdf` object — `id` discriminates the KDF -/// algorithm (only [`KDF_ID_ARGON2ID`] is accepted today), validated -/// alongside the parameter ranges in [`KdfParams::enforce_bounds`]. -/// `deny_unknown_fields` fails closed on a stray sibling (C3). +/// Argon2id parameters stored in the on-disk `kdf` object. `id` +/// discriminates the algorithm (only [`KDF_ID_ARGON2ID`] today), +/// validated with the parameter ranges in [`KdfParams::enforce_bounds`]. +/// `deny_unknown_fields` fails closed on a stray sibling. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct KdfParams { @@ -68,13 +99,54 @@ impl KdfParams { } } - /// Reject params outside the accepted bounds before any derivation - /// or allocation runs. The lower bound refuses a downgraded vault; - /// the upper bound refuses an inflated vault from an - /// attacker-controllable JSON file that would otherwise force a - /// huge allocation / unbounded derivation ahead of any tag check. - /// An unknown algorithm `id` is also a bounds failure — Argon2id is - /// the only KDF family this version supports. + /// The fastest configuration [`enforce_bounds`] still accepts — the + /// enforced floor itself, and the ONE definition of "fastest legal + /// Argon2id params" in this crate. Every test call site and + /// [`SecretStore::file_mock`] derive at it, so a suite does not pay the + /// 64 MiB target per call (#4111). + /// + /// # Panics + /// + /// Panics unless the build has `debug_assertions` on, or is this crate's + /// own test harness. This is the single choke point for weak-but-legal + /// params, so guarding it here covers EVERY caller uniformly — the mock + /// constructors inherit it rather than repeating the check. + /// + /// `cfg!(debug_assertions)` is a runtime *value*, not a `debug_assert!`: + /// it is evaluated in every profile, so the check is present precisely in + /// the optimized build it defends. If `test-util` ever reaches a release + /// build (feature unification), any path to floor params stops loudly + /// instead of silently yielding weak crypto. A `const {}` assert would + /// instead break the BUILD, taking `--release --all-features` down with + /// it; the refusal belongs at the call, not at every consumer's compile. + /// + /// [`enforce_bounds`]: KdfParams::enforce_bounds + /// [`SecretStore::file_mock`]: crate::secrets::SecretStore::file_mock + #[cfg(any(test, feature = "test-util"))] + #[expect( + clippy::assertions_on_constants, + reason = "build-configuration guard: folds to `panic!` iff test-util reached a release build" + )] + pub(crate) fn floor_target() -> Self { + assert!( + cfg!(debug_assertions) || cfg!(test), + "KdfParams::floor_target is the Argon2id FLOOR and is test-only, but this build \ + has debug_assertions off — the `test-util` feature reached a release build \ + (likely via feature unification). Refusing to hand back weak-crypto params." + ); + Self { + id: KDF_ID_ARGON2ID, + m_kib: ARGON2_MIN_M_KIB, + t: ARGON2_MIN_T, + p: ARGON2_P, + } + } + + /// Reject out-of-bounds params before any derivation/allocation: the + /// lower bound refuses a downgraded vault, the upper bound an inflated + /// one (huge allocation / unbounded derivation ahead of any tag + /// check). An unknown algorithm `id` also fails — Argon2id is the only + /// supported family. pub(crate) fn enforce_bounds(&self) -> Result<(), SecretStoreError> { if self.id != KDF_ID_ARGON2ID || self.m_kib < ARGON2_MIN_M_KIB @@ -87,32 +159,62 @@ impl KdfParams { } Ok(()) } + + /// Tier-2 envelope read gate, tighter than [`enforce_bounds`]: bounds a + /// forged header at the shipped cost instead of the 1 GiB / 16-pass DoS + /// band. An envelope's cost is paid on every read by whoever holds the + /// object password, so a forged header is a denial-of-service lever. + /// + /// Deliberately asymmetric with the FILE VAULT header, which + /// `file::derive_and_verify` accepts across the whole band. That width is + /// version tolerance, not a hardening facility: it keeps a vault written by + /// a build with a different `default_target()` openable. Narrowing it is a + /// separate decision about the vault read band, not a consequence of this + /// gate. + /// + /// Gated on the wire-stable `ARGON2_READ_MAX_*` rather than + /// `default_target()` so lowering the shipped write target can never + /// orphan an already-enrolled secret. + /// + /// [`enforce_bounds`]: KdfParams::enforce_bounds + pub(crate) fn enforce_read_ceiling(&self) -> Result<(), SecretStoreError> { + if self.m_kib > ARGON2_READ_MAX_M_KIB || self.t > ARGON2_READ_MAX_T { + return Err(SecretStoreError::KdfFailure); + } + Ok(()) + } } -/// Derive a 32-byte AEAD key from `passphrase` + `salt` with Argon2id. -/// Output lands directly in a [`SecretBytes`]. +/// Derive a 32-byte AEAD key from `passphrase` + `salt` with Argon2id, +/// landing directly in a [`SecretBytes`]. Takes `&SecretString` so the +/// bare-byte passphrase view lives only inside this function. /// -/// Takes `&SecretString` directly so the bare-byte view of the -/// passphrase lives only inside this function — callers can no -/// longer accidentally hand a `&[u8]` (e.g. by holding a stray -/// `expose_secret().as_bytes()` longer than intended) into KDF input. +/// The Argon2 block matrix is caller-owned inside [`Zeroizing`], so it is +/// wiped on every exit including the error path. Residual against A5 +/// (swap / core-dump while unlocked): that matrix is ordinary heap, not +/// `mlock`ed — a guarded allocation of up to `m_kib` does not fit the +/// locked-memory budget in `secrets/file/mod.rs`. Accepted deliberately. pub(crate) fn derive_key( passphrase: &SecretString, - salt: &[u8], + salt: &[u8; SALT_LEN], params: KdfParams, ) -> Result { - // Bounds MUST gate before Params::new / hash_password_into so an - // inflated m_kib never reaches the allocator. + // Bounds MUST gate first so an inflated m_kib never reaches the allocator. params.enforce_bounds()?; let argon_params = Params::new(params.m_kib, params.t, params.p, Some(KEY_LEN)) .map_err(|_| SecretStoreError::KdfFailure)?; + let block_count = argon_params.block_count(); let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon_params); let mut key = SecretBytes::zeroed(KEY_LEN); + // Argon2 0.5.3 does not wipe its internally allocated matrix. A boxed + // slice keeps our key-equivalent working memory fixed in place until drop. + let mut blocks = Zeroizing::new(vec![Block::default(); block_count].into_boxed_slice()); argon - .hash_password_into( + .hash_password_into_with_memory( passphrase.expose_secret().as_bytes(), salt, key.expose_secret_mut(), + &mut blocks, ) .map_err(|_| SecretStoreError::KdfFailure)?; Ok(key) @@ -126,7 +228,7 @@ pub(crate) fn seal( plaintext: &[u8], ) -> Result<([u8; NONCE_LEN], Vec), SecretStoreError> { let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret()) - .map_err(|_| SecretStoreError::KdfFailure)?; + .map_err(|_| SecretStoreError::Encrypt)?; let mut nonce_bytes = [0u8; NONCE_LEN]; random_bytes(&mut nonce_bytes)?; let nonce = XNonce::from_slice(&nonce_bytes); @@ -138,11 +240,36 @@ pub(crate) fn seal( aad, }, ) - // Encrypt-path failure (XChaCha20-Poly1305 only fails here when - // the plaintext exceeds the construction's length limit), so it is - // not a decryption concern; keep it on the same write-oriented - // variant the cipher-construction failure above uses. - .map_err(|_| SecretStoreError::KdfFailure)?; + // AEAD write-side failure (only when plaintext exceeds the length + // limit), not a key-derivation one. + .map_err(|_| SecretStoreError::Encrypt)?; + Ok((nonce_bytes, ct)) +} + +/// Like [`seal`] but takes a caller-supplied `nonce` instead of pulling +/// from the CSPRNG. **Test-only** — golden-vector / size-budget tests +/// need byte-deterministic ciphertext output. Production code MUST use +/// [`seal`] so nonces stay unique (XChaCha20-Poly1305 nonce reuse leaks +/// the keystream). +#[cfg(test)] +pub(crate) fn seal_with_nonce( + key: &SecretBytes, + nonce_bytes: [u8; NONCE_LEN], + aad: &[u8], + plaintext: &[u8], +) -> Result<([u8; NONCE_LEN], Vec), SecretStoreError> { + let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret()) + .map_err(|_| SecretStoreError::Encrypt)?; + let nonce = XNonce::from_slice(&nonce_bytes); + let ct = cipher + .encrypt( + nonce, + chacha20poly1305::aead::Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| SecretStoreError::Encrypt)?; Ok((nonce_bytes, ct)) } @@ -157,7 +284,7 @@ pub(crate) fn open( ciphertext: &[u8], ) -> Result { let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret()) - .map_err(|_| SecretStoreError::KdfFailure)?; + .map_err(|_| SecretStoreError::Encrypt)?; let nonce = XNonce::from_slice(nonce); let pt = cipher .decrypt( @@ -174,21 +301,120 @@ pub(crate) fn open( #[cfg(test)] mod tests { use super::*; + use zeroize::Zeroize; - /// Argon2id floor params — fast enough for unit tests; production - /// runs at the default target (64 MiB). - fn floor_params() -> KdfParams { - KdfParams { - id: KDF_ID_ARGON2ID, - m_kib: ARGON2_MIN_M_KIB, - t: ARGON2_MIN_T, - p: ARGON2_P, - } + // Compile-time guard: argon2's `impl Zeroize for Block` is feature- + // gated, so this fails to build if `argon2/zeroize` is ever dropped. + static_assertions::assert_impl_all!(argon2::Block: zeroize::Zeroize); + + /// **Vault-opening invariant — do not "fix" by updating an expected + /// value.** Caller-owned working memory changed only WHERE the Argon2 + /// matrix lives, never what it derives. Should this ever diverge from + /// argon2's own `hash_password_into`, every existing vault and every + /// enrolled Tier-2 secret stops opening, reported as a wrong + /// passphrase, with no recovery path. + #[test] + fn derive_key_matches_upstream_reference_derivation() { + const PW: &str = "correct horse battery"; + let salt = [0x5Au8; SALT_LEN]; + let params = KdfParams::floor_target(); + let derived = derive_key(&SecretString::new(PW), &salt, params).unwrap(); + + let argon_params = Params::new(params.m_kib, params.t, params.p, Some(KEY_LEN)).unwrap(); + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon_params); + let mut reference = [0u8; KEY_LEN]; + argon + .hash_password_into(PW.as_bytes(), &salt, &mut reference) + .unwrap(); + + assert_eq!( + derived.expose_secret(), + &reference[..], + "caller-owned Argon2 memory changed the derived key" + ); + reference.zeroize(); + } + + /// The Tier-2 read ceiling is its own wire-format bound, not a mirror + /// of the shipped write target: exactly-at-ceiling derives, one step + /// over on either axis is refused. + #[test] + fn read_ceiling_accepts_its_bound_and_refuses_above_it() { + let at_ceiling = KdfParams { + m_kib: ARGON2_READ_MAX_M_KIB, + t: ARGON2_READ_MAX_T, + ..KdfParams::default_target() + }; + assert!(at_ceiling.enforce_read_ceiling().is_ok()); + assert!(matches!( + KdfParams { + m_kib: ARGON2_READ_MAX_M_KIB + 1, + ..at_ceiling + } + .enforce_read_ceiling(), + Err(SecretStoreError::KdfFailure) + )); + assert!(matches!( + KdfParams { + t: ARGON2_READ_MAX_T + 1, + ..at_ceiling + } + .enforce_read_ceiling(), + Err(SecretStoreError::KdfFailure) + )); + // No build may ship a write target its own read path refuses; the + // const assert enforces it, this pins the behaviour. + assert!(KdfParams::default_target().enforce_read_ceiling().is_ok()); + assert!(KdfParams::floor_target().enforce_read_ceiling().is_ok()); + } + + /// The block matrix is key-equivalent state, so zeroization must + /// leave none of it behind. Filled through argon2's public + /// `fill_memory`, so the wipe is proven against REAL derived material + /// rather than a synthetic pattern. + #[test] + fn argon2_block_matrix_is_wiped_before_release() { + // Deliberately tiny (8 KiB): this exercises matrix zeroization, not the + // production cost parameters. + let params = Params::new(8, 1, 1, Some(KEY_LEN)).unwrap(); + let block_count = params.block_count(); + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut blocks = Zeroizing::new(vec![Block::default(); block_count].into_boxed_slice()); + argon + .fill_memory(b"passphrase", &[7u8; SALT_LEN], &mut blocks) + .unwrap(); + + let nonzero_words = |b: &[Block]| { + b.iter() + .flat_map(|block| { + let words: &[u64] = block.as_ref(); + words.iter() + }) + .filter(|w| **w != 0) + .count() + }; + assert!( + nonzero_words(blocks.as_mut()) > 0, + "fixture must hold real derived state before the wipe" + ); + + blocks.zeroize(); + assert_eq!( + blocks.len(), + block_count, + "the wipe must preserve the matrix" + ); + + assert_eq!( + nonzero_words(blocks.as_mut()), + 0, + "Argon2 working memory survived the wipe" + ); } #[test] fn floors_reject_weak_params() { - let base = floor_params(); + let base = KdfParams::floor_target(); assert!(KdfParams { m_kib: 1024, ..base @@ -204,7 +430,7 @@ mod tests { fn ceilings_reject_inflated_params() { // An attacker-controllable JSON kdf cannot force a huge // allocation or unbounded derivation. - let base = floor_params(); + let base = KdfParams::floor_target(); assert!(KdfParams { m_kib: u32::MAX, ..base @@ -239,7 +465,7 @@ mod tests { // algorithm id is refused before any derivation runs. let bad = KdfParams { id: 7, - ..floor_params() + ..KdfParams::floor_target() }; assert!(matches!( bad.enforce_bounds(), @@ -253,16 +479,14 @@ mod tests { #[test] fn derive_key_rejects_inflated_m_kib_before_allocating() { - // u32::MAX m_kib must error fast (enforce_bounds) and never reach - // the multi-GiB allocator. A real allocation of ~4 TiB would OOM - // the test, so reaching here at all proves the ceiling fired - // first. + // u32::MAX m_kib must error via enforce_bounds before the ~4 TiB + // allocation — which would OOM the test if it ever ran. let err = derive_key( &SecretString::new("pw"), &[0u8; SALT_LEN], KdfParams { m_kib: u32::MAX, - ..floor_params() + ..KdfParams::floor_target() }, ) .unwrap_err(); @@ -273,7 +497,12 @@ mod tests { fn seal_open_roundtrip_with_floor_params() { let mut salt = [0u8; SALT_LEN]; random_bytes(&mut salt).unwrap(); - let key = derive_key(&SecretString::new("correct horse"), &salt, floor_params()).unwrap(); + let key = derive_key( + &SecretString::new("correct horse"), + &salt, + KdfParams::floor_target(), + ) + .unwrap(); let aad = b"v1|wallet|label"; let (nonce, ct) = seal(&key, aad, b"top secret seed").unwrap(); let pt = open(&key, &nonce, aad, &ct).unwrap(); @@ -282,7 +511,12 @@ mod tests { #[test] fn wrong_aad_fails_with_no_plaintext() { - let key = derive_key(&SecretString::new("pw"), &[9u8; SALT_LEN], floor_params()).unwrap(); + let key = derive_key( + &SecretString::new("pw"), + &[9u8; SALT_LEN], + KdfParams::floor_target(), + ) + .unwrap(); let (nonce, ct) = seal(&key, b"slot-A", b"seed").unwrap(); let err = open(&key, &nonce, b"slot-B", &ct).unwrap_err(); assert!(matches!(err, SecretStoreError::Decrypt)); @@ -291,8 +525,18 @@ mod tests { #[test] fn wrong_key_fails() { let salt = [1u8; SALT_LEN]; - let k1 = derive_key(&SecretString::new("right"), &salt, floor_params()).unwrap(); - let k2 = derive_key(&SecretString::new("wrong"), &salt, floor_params()).unwrap(); + let k1 = derive_key( + &SecretString::new("right"), + &salt, + KdfParams::floor_target(), + ) + .unwrap(); + let k2 = derive_key( + &SecretString::new("wrong"), + &salt, + KdfParams::floor_target(), + ) + .unwrap(); let (nonce, ct) = seal(&k1, b"aad", b"seed").unwrap(); assert!(matches!( open(&k2, &nonce, b"aad", &ct), @@ -302,7 +546,12 @@ mod tests { #[test] fn nonces_are_unique_across_seals() { - let key = derive_key(&SecretString::new("pw"), &[2u8; SALT_LEN], floor_params()).unwrap(); + let key = derive_key( + &SecretString::new("pw"), + &[2u8; SALT_LEN], + KdfParams::floor_target(), + ) + .unwrap(); let mut seen = std::collections::HashSet::new(); for _ in 0..256 { let (nonce, _) = seal(&key, b"aad", b"x").unwrap(); diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs index d188658dd3b..89ec269cceb 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs @@ -1,10 +1,7 @@ //! Versioned, self-describing vault format + canonical AAD. //! -//! The vault is one `serde_json` document covering every wallet in the -//! store: a single passphrase / salt / KDF block at the top, and a -//! nested map keyed first by `wallet_id` (lowercase hex) and then by -//! `label`. One file, one passphrase, one lock — a multi-wallet store -//! cannot lock its other wallets out by construction. +//! The vault is one `serde_json` document: a single salt / KDF block at +//! the top, then a map keyed by `wallet_id` (lowercase hex) and `label`. //! //! ```json //! { @@ -21,22 +18,36 @@ //! } //! ``` //! -//! Entries are nested `BTreeMap`s so lookup is O(log n) and the on-disk -//! shape excludes duplicate `(wallet_id, label)` pairs by construction -//! (a JSON object cannot carry two values under the same key). +//! Nested `BTreeMap`s give O(log n) lookup and a JSON-object shape that +//! excludes duplicate `(wallet_id, label)` pairs by construction on the +//! WRITE side. On the READ side, a hand-edited document with duplicate JSON +//! keys is not rejected — `serde_json` collapses duplicates last-wins into +//! the `BTreeMap`. That is benign: every entry's ciphertext is AEAD-sealed +//! with its `(wallet_id, label)` bound as AAD, so a collapsed or reordered +//! structure can never surface bytes that don't authenticate against the +//! surviving key (a forged duplicate fails its tag as `Corruption`). //! //! Parsing is two-step: a lax [`VersionProbe`] reads `version` first -//! (tolerating future-version sibling fields), then — only for the -//! compiled-in [`FORMAT_VERSION`] — the strict [`Vault`] payload is -//! parsed. All byte fields are lowercase hex; Argon2 params are JSON -//! numbers. +//! (tolerating future-version siblings), then the strict [`Vault`] +//! payload is parsed only for the compiled-in [`FORMAT_VERSION`]. //! -//! KDF params/salt are store-wide. `verify_ct` is an AEAD seal of a -//! fixed constant under the header-derived key — a wrong passphrase -//! fails its tag, so a mismatched key is rejected before any entry is -//! written or read (no mixed-key corruption). The verify-token AAD is -//! NOT bound to any wallet id (the store is now multi-wallet) so the -//! token validates the store-wide passphrase exactly once per op. +//! `verify_ct` is an AEAD seal of a fixed constant under the +//! header-derived key, so a wrong passphrase fails its tag and a +//! mismatched key is rejected before any entry is touched (no mixed-key +//! corruption). The verify-token AAD is not bound to any wallet id, so it +//! validates the store-wide passphrase once per op. +//! +//! # Warning: the header is authenticated — do not hand-edit it +//! +//! The document is human-readable JSON and the `kdf` block is legible, but it +//! is not a settings file. `kdf` and `salt` feed BOTH the derived key and the +//! verify-token AAD, so changing either byte-wise makes the vault permanently +//! unopenable — and the failure surfaces as +//! [`SecretStoreError::WrongPassphrase`](crate::secrets::SecretStoreError), +//! because an edited header and a mistyped passphrase are cryptographically +//! indistinguishable at that point. There is no API that raises a vault's +//! Argon2 cost and no supported way to do it by hand; a vault edited this way +//! is recoverable only from a backup. use std::collections::BTreeMap; @@ -44,6 +55,9 @@ use serde::{Deserialize, Serialize}; use super::crypto::{KdfParams, NONCE_LEN, SALT_LEN}; use crate::secrets::error::SecretStoreError; +use crate::secrets::wire::aad::{EntryAad, VerifyAad}; +use crate::secrets::wire::config::{ENTRY_DOMAIN_V2, VERIFY_DOMAIN_V2, WIRE_CONFIG}; +use crate::secrets::wire::kdf::KdfParamsEncoded; pub(crate) const FORMAT_VERSION: u32 = 1; pub(crate) const KDF_ID_ARGON2ID: u8 = 1; @@ -53,35 +67,16 @@ pub(crate) const KDF_ID_ARGON2ID: u8 = 1; /// value itself is not secret. pub(crate) const VERIFY_CONSTANT: &[u8] = b"PWSVAULT-VERIFY-v1"; -/// AAD slot label for the verification token. The leading NUL keeps it -/// disjoint from every allowlisted entry label, so the token can never -/// alias a real entry's AAD. -pub(crate) const VERIFY_LABEL: &str = "\0verify"; - -/// Sentinel wallet id used as the verify-token AAD's wallet slot. The -/// store-wide token is not bound to any real wallet; this 32-byte zero -/// id keeps the AAD shape identical to entry AAD (same length-prefixed -/// construction) without aliasing a real wallet's namespace — a real -/// wallet id `[0u8; 32]` would still produce a different AAD because -/// the label slot differs ([`VERIFY_LABEL`] vs any allowlisted label). -const VERIFY_WALLET_ID: [u8; 32] = [0u8; 32]; - /// Minimum AEAD ciphertext length: the Poly1305 tag is always present /// even for an empty plaintext, so any `verify_ct`/`ciphertext` shorter /// than this is structurally impossible and rejected. const AEAD_TAG_LEN: usize = 16; -/// The full parsed vault: format `version`, KDF parameters, salt, the -/// passphrase-verification token, and every wallet's entries. -/// Serializes directly to the on-disk wire form — `hex_array` validates -/// `salt`/`verify_nonce` widths at the serde seam, so no parallel -/// `Vec`-typed wire mirror is needed. Field order matches the -/// documented schema and `serde_json` preserves it, so the byte layout -/// is stable. -/// -/// `deny_unknown_fields` fails closed on a stray sibling for this -/// compiled-in [`FORMAT_VERSION`] (C3). Forward-compat dispatch on -/// `version` runs through [`VersionProbe`] before this strict parse. +/// The full parsed vault, serializing directly to the on-disk wire form. +/// `hex_array` validates fixed-width fields at the serde seam, and +/// `serde_json` preserves field order, so the byte layout is stable. +/// `deny_unknown_fields` fails closed on a stray sibling; forward-compat +/// dispatch runs through [`VersionProbe`] before this strict parse. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct Vault { @@ -99,12 +94,9 @@ pub(crate) struct Vault { pub wallets: BTreeMap>, } -/// One decrypted-on-demand vault entry body. The owning -/// `Vault.wallets[wallet]` `BTreeMap` keys this by `label`, so the -/// label is the map key — not a field — and the on-disk shape can't -/// carry two entries under the same label. `hex_array` validates -/// `nonce`'s fixed width at parse; `deny_unknown_fields` fails closed -/// on a stray sibling (C3). +/// One vault entry body, keyed by `label` in the owning `BTreeMap` (so +/// the label is the map key, not a field). `hex_array` validates `nonce`'s +/// width at parse; `deny_unknown_fields` fails closed on a stray sibling. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EntryBody { @@ -114,42 +106,49 @@ pub(crate) struct EntryBody { pub ciphertext: Vec, } -/// Canonical length-prefixed AAD binding ciphertext to its slot: -/// `format_version ‖ wallet_id ‖ label`. A blob moved to another slot, -/// or a rolled-back `format_version`, fails the tag. +/// Canonical AAD binding a vault entry's ciphertext to its slot: +/// `domain ‖ format_version ‖ wallet_id ‖ label`, bincode-encoded +/// against [`WIRE_CONFIG`]. A blob moved to another slot, or one +/// version-rolled-back, fails the tag. /// -/// AAD-DETERMINISM INVARIANT (C1): AAD is built solely from the typed -/// `(format_version, wallet_id, label)` triple via this length-prefixed -/// layout — never from any serialized JSON bytes or JSON key order. The -/// `format_version` argument is always the compiled-in [`FORMAT_VERSION`] -/// constant at every call site; the JSON `version` field is used ONLY as -/// the two-step dispatch gate and is NEVER routed into AAD. +/// Determinism invariant: AAD is built solely from this typed triple, +/// never from serialized JSON bytes or key order. `format_version` is +/// always the compiled-in [`FORMAT_VERSION`]; the JSON `version` field +/// is a dispatch gate only and is never routed into AAD. pub(crate) fn aad(format_version: u32, wallet_id: &[u8; 32], label: &str) -> Vec { - let lb = label.as_bytes(); - let mut v = Vec::with_capacity(4 + 4 + 32 + 4 + lb.len()); - v.extend_from_slice(&format_version.to_le_bytes()); - v.extend_from_slice(&(wallet_id.len() as u32).to_le_bytes()); - v.extend_from_slice(wallet_id); - v.extend_from_slice(&(lb.len() as u32).to_le_bytes()); - v.extend_from_slice(lb); - v + bincode::encode_to_vec( + EntryAad { + domain: ENTRY_DOMAIN_V2, + format_version, + wallet_id: *wallet_id, + label, + }, + WIRE_CONFIG, + ) + .expect("EntryAad encode is infallible") } -/// AAD for the passphrase-verification token. Uses the same canonical -/// construction as entry AAD but with a sentinel zero wallet id and -/// [`VERIFY_LABEL`] (NUL-prefixed, disjoint from every allowlisted -/// label) so the token is cryptographically tied to this -/// `format_version` only and cannot be replayed into any real entry -/// slot. -pub(crate) fn verify_aad(format_version: u32) -> Vec { - aad(format_version, &VERIFY_WALLET_ID, VERIFY_LABEL) +/// AAD for the verify-token: bincode-encoded `VerifyAad` binding the +/// vault-wide salt + KDF header against the verify domain tag. A +/// tampered header yields a different AAD AND a different derived key, +/// so the token surfaces `WrongPassphrase`. +pub(crate) fn verify_aad(format_version: u32, salt: &[u8; SALT_LEN], kdf: &KdfParams) -> Vec { + bincode::encode_to_vec( + VerifyAad { + domain: VERIFY_DOMAIN_V2, + format_version, + salt: *salt, + kdf: KdfParamsEncoded::from(*kdf), + }, + WIRE_CONFIG, + ) + .expect("VerifyAad encode is infallible") } -/// Serde helpers encoding `Vec` as lowercase hex strings. Hex is -/// already a crate dependency (`WalletId::to_hex`), is deterministic and -/// self-validating, and avoids adding `base64`. The encoding sits wholly -/// outside the AEAD envelope and the AAD (C1), so it has no bearing on -/// any cryptographic binding. +/// Serde helpers encoding `Vec` as lowercase hex. Hex is already a +/// crate dependency, deterministic, and avoids adding `base64`. The +/// encoding sits outside the AEAD envelope and the AAD, so it has no +/// cryptographic bearing. mod hex_bytes { use serde::{Deserialize, Deserializer, Serializer}; @@ -163,12 +162,10 @@ mod hex_bytes { } } -/// Const-generic companion to [`hex_bytes`] for fixed-width byte fields. -/// Wire form is identical (lowercase hex), but the `[u8; N]` deserialize -/// target moves length validation into the serde seam — a wrong-length -/// hex blob is rejected at parse with a `serde::de::Error` naming both -/// the offending size and the expected `N`, so the field is identifiable -/// in the error message (no anonymous "invalid length"). +/// Const-generic companion to [`hex_bytes`] for fixed-width fields. The +/// `[u8; N]` target moves length validation into the serde seam: a +/// wrong-length blob is rejected at parse with an error naming the +/// offending size and the expected `N`. pub(super) mod hex_array { use serde::{de::Error as DeError, Deserialize, Deserializer, Serializer}; @@ -201,33 +198,32 @@ pub(super) mod hex_array { } } -/// Step-1 probe: read ONLY `version`, tolerating unknown sibling fields -/// so a future v-N file can be dispatched on before its payload shape is -/// committed to. MUST NOT use `deny_unknown_fields` (C3). +/// Step-1 probe: read ONLY `version`, tolerating unknown siblings so a +/// future vN file can be dispatched on. MUST NOT use `deny_unknown_fields`. #[derive(Deserialize)] struct VersionProbe { version: u32, } -/// Serialize a full vault to JSON bytes. Contains only salt/params -/// (non-secret) + ciphertext — never plaintext. +/// Serialize a vault to JSON bytes — salt/params + ciphertext only, never +/// plaintext. pub(crate) fn serialize(vault: &Vault) -> Vec { - // Vault carries only fixed-width arrays and owned Vecs that serialize - // infallibly; a serializer error would be a logic bug. + // Vault holds only fixed arrays and owned Vecs; serialization is + // infallible, so an error would be a logic bug. serde_json::to_vec(vault).expect("vault serialization is infallible") } -/// Parse a vault. Two-step: probe `version` (lax), then parse the strict -/// payload for the known version. Refuses unknown versions and any -/// malformed/short byte field — fail closed. Unknown KDF -/// algorithm ids and out-of-range Argon2 params are caught later at -/// `KdfParams::enforce_bounds` (called on every `derive_key`), so they -/// can't silently slip past. All `serde_json` errors are mapped to a -/// static [`SecretStoreError`] with the source DISCARDED so input bytes -/// can never leak into an error string or log. Salt and nonce widths -/// are validated by `hex_array` at the serde seam; the AEAD-tag-length -/// floor remains a post-parse check. +/// Parse a vault: probe `version` (lax), then parse the strict payload +/// for the known version. Fails closed on unknown versions and malformed +/// fields. `serde_json` errors are mapped to a static +/// [`SecretStoreError`] with the source DISCARDED so input bytes never +/// leak. Unknown KDF ids / out-of-range Argon2 params are caught later at +/// `KdfParams::enforce_bounds`. pub(crate) fn deserialize(buf: &[u8]) -> Result { + // INTENTIONAL: the 2x parse (probe + strict) over the 128MiB-capped, + // lock-gated local file is accepted for forward-version dispatch. + // INTENTIONAL: relies on serde_json's default recursion limit (128) + // for deep-nesting DoS safety — MUST NOT disable it or use from_reader. let probe: VersionProbe = serde_json::from_slice(buf).map_err(|_| SecretStoreError::MalformedVault)?; if probe.version != FORMAT_VERSION { @@ -242,12 +238,9 @@ pub(crate) fn deserialize(buf: &[u8]) -> Result { return Err(SecretStoreError::MalformedVault); } - // Validate outer wallet-id keys and inner label keys at parse time. - // The serde shape allows any string for either key, so - // a malformed file (or a tampered one) could otherwise smuggle a - // bogus wallet id past parse and surface only at the first `put` / - // `get` / `delete`. Reject the whole vault on the first offender so - // a single bad key fails the file open, not a downstream op. + // Validate wallet-id and label keys at parse: the serde shape allows + // any string, so a bogus key would otherwise surface only at the + // first put/get/delete. Reject the whole vault on the first offender. for (wallet_hex, entries) in &vault.wallets { super::decode_wallet_id_hex(wallet_hex)?; for (label, body) in entries { @@ -266,33 +259,6 @@ pub(crate) fn deserialize(buf: &[u8]) -> Result { mod tests { use super::*; - #[test] - fn aad_binds_slot() { - let w = [1u8; 32]; - assert_ne!(aad(1, &w, "a"), aad(1, &w, "b")); - assert_ne!(aad(1, &w, "a"), aad(2, &w, "a")); - assert_ne!(aad(1, &w, "a"), aad(1, &[2u8; 32], "a")); - // Length-prefix defeats `"a"+"bc"` vs `"ab"+"c"` ambiguity. - assert_ne!(aad(1, &w, "ab"), { - let mut v = aad(1, &w, "a"); - v.extend_from_slice(b"b"); - v - }); - } - - #[test] - fn verify_aad_disjoint_from_every_entry_aad() { - // The verify-token's slot is `(VERIFY_WALLET_ID, VERIFY_LABEL)`. - // VERIFY_LABEL starts with NUL, which the allowlist forbids, so - // no real entry's AAD can collide with the token's AAD — even - // if a caller happens to register the all-zero wallet id. - let v = verify_aad(FORMAT_VERSION); - // A real entry on the same sentinel wallet id can never match - // because its label cannot contain NUL. - assert_ne!(v, aad(FORMAT_VERSION, &VERIFY_WALLET_ID, "seed")); - assert_ne!(v, aad(FORMAT_VERSION, &[1u8; 32], "seed")); - } - fn test_vault(wallets: BTreeMap>) -> Vault { Vault { version: FORMAT_VERSION, @@ -366,9 +332,8 @@ mod tests { #[test] fn deserialize_accepts_unknown_kdf_id_and_bounds_check_rejects_later() { - // Unknown algo ids ride through parse so the algorithm gate - // lives in one place — `KdfParams::enforce_bounds`, called on - // every `derive_key`. The format layer no longer guards it. + // Unknown algo ids ride through parse; the gate lives solely at + // `KdfParams::enforce_bounds` (called on every `derive_key`). let mut vault = test_vault(BTreeMap::new()); vault.kdf.id = 7; let bytes = serialize(&vault); @@ -618,4 +583,146 @@ mod tests { "error leaked input bytes: {rendered}" ); } + + /// A parse of mutated bytes must be a clean `Ok` or a typed error + /// variant — never a panic / abort. + fn assert_deserialize_outcome_is_typed(bytes: &[u8]) { + let res = std::panic::catch_unwind(|| deserialize(bytes)); + let parsed = res.expect("deserialize must never panic on hostile input"); + match parsed { + Ok(_) + | Err(SecretStoreError::MalformedVault) + | Err(SecretStoreError::VersionUnsupported { .. }) + | Err(SecretStoreError::InvalidLabel) => {} + Err(other) => panic!("unexpected error variant from parser: {other:?}"), + } + } + + /// Deterministic byte-level fuzz: flip bytes and truncate at every + /// offset of a valid vault, asserting the parser stays fail-closed and + /// never panics. Fixed seed, no proptest dependency. + #[test] + fn parser_is_fuzz_resistant_to_byte_mutation() { + let mut entries = BTreeMap::new(); + entries.insert( + "bip39_mnemonic".to_string(), + EntryBody { + nonce: [0x33; NONCE_LEN], + ciphertext: vec![0x44; AEAD_TAG_LEN + 16], + }, + ); + let mut wallets = BTreeMap::new(); + wallets.insert(hex::encode([0xABu8; 32]), entries); + let valid = serialize(&test_vault(wallets)); + + // The pristine vault parses. + assert!(deserialize(&valid).is_ok()); + + // xorshift32 — deterministic, std-only. + let mut state: u32 = 0x1234_5678; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state + }; + + for _ in 0..2_000 { + let mut buf = valid.clone(); + // Flip 1..=4 random bytes. + let flips = 1 + (next() % 4) as usize; + for _ in 0..flips { + let idx = (next() as usize) % buf.len(); + buf[idx] ^= (next() & 0xFF) as u8; + } + assert_deserialize_outcome_is_typed(&buf); + } + + // Truncation at every offset — a short read must never panic. + for cut in 0..valid.len() { + assert_deserialize_outcome_is_typed(&valid[..cut]); + } + } + + /// Structural fuzz: hostile shapes a byte-flip rarely hits (oversized + /// KDF params, deep nesting, bad labels, wrong-width hex). Each must be + /// a typed error or a valid Ok, never a panic. Inflated KDF params + /// parse Ok by design (the bounds gate lives at `derive_key`). + #[test] + fn parser_is_fuzz_resistant_to_structural_mutation() { + let base: serde_json::Value = + serde_json::from_slice(&serialize(&test_vault(BTreeMap::new()))).unwrap(); + let wid_owned = hex::encode([1u8; 32]); + let wid = wid_owned.as_str(); + let good_nonce = "0".repeat(NONCE_LEN * 2); + let good_ct = "0".repeat((AEAD_TAG_LEN + 1) * 2); + + let mut cases: Vec = Vec::new(); + + // Oversized / absurd KDF params. + for (k, v) in [ + ("m_kib", serde_json::json!(u32::MAX)), + ("t", serde_json::json!(u32::MAX)), + ("p", serde_json::json!(u32::MAX)), + ("id", serde_json::json!(255)), + ] { + let mut c = base.clone(); + c["kdf"][k] = v; + cases.push(c); + } + + // Deep nesting in the wallets map (well past the type's depth). + { + let mut nested = serde_json::json!(0); + for _ in 0..512 { + nested = serde_json::json!([nested]); + } + let mut c = base.clone(); + c["wallets"] = nested; + cases.push(c); + } + + // Hostile labels and key shapes. + for label in ["\0null", "../escape", &"a".repeat(65), "has space"] { + let mut c = base.clone(); + c["wallets"] = serde_json::json!({ wid: { label: { "nonce": good_nonce.as_str(), "ciphertext": good_ct.as_str() } } }); + cases.push(c); + } + + // Wrong-width hex and oversized declared sizes. + for (nonce, ct) in [ + ("00", good_ct.as_str()), // short nonce + (good_nonce.as_str(), "00"), // short ciphertext + (&"0".repeat(NONCE_LEN * 4), good_ct.as_str()), // over-wide nonce + ("zz", good_ct.as_str()), // non-hex nonce + ] { + let mut c = base.clone(); + c["wallets"] = + serde_json::json!({ wid: { "seed": { "nonce": nonce, "ciphertext": ct } } }); + cases.push(c); + } + + // Non-hex / wrong-length outer wallet-id key. + for bad_wid in ["not-hex", &"aa".repeat(8), &"AB".repeat(32)] { + let mut c = base.clone(); + c["wallets"] = serde_json::json!({ bad_wid: { "seed": { "nonce": good_nonce.as_str(), "ciphertext": good_ct.as_str() } } }); + cases.push(c); + } + + // Header fields (salt / verify_nonce / verify_ct): empty / short / + // over-wide / non-hex must each be a typed error, never a panic. + let over_wide = "0".repeat(SALT_LEN * 4); + for field in ["salt", "verify_nonce", "verify_ct"] { + for bad in ["", "00", over_wide.as_str(), "zz"] { + let mut c = base.clone(); + c[field] = serde_json::json!(bad); + cases.push(c); + } + } + + for c in cases { + let bytes = serde_json::to_vec(&c).unwrap(); + assert_deserialize_outcome_is_typed(&bytes); + } + } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs index c2ae67e7352..4f040149ddc 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs @@ -1,28 +1,24 @@ //! [`EncryptedFileStore`] — passphrase-encrypted on-disk vault, resident //! in memory while the store handle lives. //! -//! # Lifecycle +//! [`open`] takes the advisory lock on a sibling `.lock` sidecar (single +//! attempt), then creates or decrypts the vault and keeps the plaintext +//! entry map resident. [`get`] serves from memory (no per-op KDF/disk); +//! every mutation ([`put`], [`delete`], [`rekey`]) edits memory then +//! re-encrypts and atomically rewrites the file. [`Drop`] best-effort +//! re-syncs, re-asserts `0600` on Unix, and releases the lock. A second +//! `open()` of a held path fails fast with +//! [`SecretStoreError::AlreadyLocked`]. //! -//! - [`open`] grabs the cross-platform advisory lock on a sibling -//! `.lock` sidecar (single attempt, no retry), creates a fresh vault -//! if none exists yet, otherwise decrypts the existing one, and keeps -//! the plaintext entry map resident. -//! - Every mutation ([`put`], [`delete`], [`rekey`]) edits the in-memory -//! vault and immediately re-encrypts and atomically writes it back to -//! disk (eager sync). -//! - [`get`] reads from the in-memory map — no KDF, no disk hit per op. -//! - [`Drop`] best-effort-syncs the resident state once more, re-asserts -//! `0600` on Unix, and releases the lock when the file descriptor -//! closes. +//! **Cross-process exclusion is LOCAL-filesystem only.** `AlreadyLocked` +//! rests on `fd-lock` (`flock` / `LockFileEx`), which does NOT interlock +//! over NFS/CIFS/SMB — two hosts could each "lock" and last-writer-wins +//! would lose secrets. A vault file MUST NOT be shared across hosts; use +//! the OS keyring ([`SecretStore::Os`]) for multi-host access. The lock +//! sidecar is distinct from the vault file so the atomic `persist` rename +//! never touches the inode the open lock fd points at. //! -//! Concurrency is intentionally not supported: a second `open()` against -//! a path some other store handle (in this or another process) is -//! already holding fails fast with [`SecretStoreError::AlreadyLocked`]. -//! -//! One file, one passphrase, one lock — a multi-wallet store cannot -//! lock its other wallets out by construction. The lock sidecar -//! (`.lock`) is distinct from the vault file itself so the atomic -//! `persist` rename never touches the inode an open lock fd points at. +//! [`SecretStore::Os`]: crate::secrets::SecretStore::Os //! //! [`open`]: EncryptedFileStore::open //! [`put`]: EncryptedFileStore::put_bytes @@ -30,27 +26,28 @@ //! [`rekey`]: EncryptedFileStore::rekey //! [`get`]: EncryptedFileStore::get_bytes //! -//! ## Threat coverage -//! -//! Covers **A1** (other local user), **A4** (lost laptop / cold -//! backup), **A6** (synced backup of the vault file): the at-rest file -//! is Argon2id + AEAD, useless without the passphrase. Does **not** -//! cover **A3** (passphrase / derived key resident while unlocked), a -//! weak operator passphrase (KDF raises cost, does not eliminate the -//! risk — an accepted residual), or **A5** if the derived key / plaintext is -//! swapped or core-dumped while unlocked (best-effort mitigated by -//! zeroize + mlock, not eliminated). The derived AEAD key is held -//! resident inside a [`SecretBytes`] for the store's lifetime so reads -//! and writes do not pay the Argon2 cost per op; it is zeroized on Drop. - -mod crypto; -mod format; +//! Threat coverage: the at-rest file is Argon2id + AEAD, so it protects +//! **A1** (other local user), **A4** (lost laptop / cold backup), and +//! **A6** (synced backup). It does NOT cover **A3** (key/passphrase +//! resident while unlocked), a weak operator passphrase, or **A5** +//! (swap / core-dump while unlocked) — the last is best-effort mitigated +//! by zeroize + mlock. The derived AEAD key stays resident in a +//! [`SecretBytes`] (to avoid per-op Argon2) and is zeroized on Drop. + +// `pub(super)` (= visible within `crate::secrets`) so the Tier-2 +// `envelope` module — a sibling of `file` under `secrets` — can reuse the +// shared Argon2id/XChaCha primitives and `KDF_ID_ARGON2ID` without +// duplicating crypto. Items inside stay `pub(crate)`/`pub(in …file)`, so +// nothing escapes the secrets tree (see the crypto.rs module doc). +pub(super) mod crypto; +pub(super) mod format; use std::any::Any; use std::collections::HashMap; use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use keyring_core::api::{Credential, CredentialApi, CredentialPersistence, CredentialStoreApi}; @@ -60,13 +57,13 @@ use crypto::{KdfParams, SALT_LEN}; use format::{EntryBody, Vault}; use super::error::SecretStoreError; +use super::guarded::verify_host_page_size; -use super::secret::{SecretBytes, SecretString}; +use super::secret::{SecretBytes, SecretString, MAX_PASSPHRASE_LEN}; use super::validate::{validated_label, WalletId}; -/// Upstream service-prefix for vault entries. The full `service` -/// string is `SERVICE_PREFIX + hex(wallet_id)`, mapping each wallet -/// to its own keyring "service" namespace. +/// Service-prefix for vault entries: the full `service` string is +/// `SERVICE_PREFIX + hex(wallet_id)`, one namespace per wallet. pub const SERVICE_PREFIX: &str = "dash.platform-wallet-storage/"; /// Vendor / id tags published through `CredentialStoreApi`. @@ -74,116 +71,326 @@ const VENDOR: &str = "dash.platform-wallet-storage"; const STORE_ID: &str = "encrypted-file-store-v1"; /// Structural ceiling on the on-disk vault file. The vault is -/// attacker-controllable JSON; a multi-GiB file would force a huge -/// `fs::read` allocation ahead of any tag check, so refuse to even -/// allocate beyond this cap and surface -/// [`SecretStoreError::VaultTooLarge`]. +/// attacker-controllable JSON, so refuse to allocate / parse beyond this +/// cap (surfacing [`SecretStoreError::VaultTooLarge`]) ahead of any tag +/// check rather than let a multi-GiB file force a huge `fs::read`. pub const MAX_VAULT_SIZE_BYTES: u64 = 128 * 1024 * 1024; -/// A passphrase-encrypted file-backed credential store. +/// Per-secret write-side ceiling. The vault is ONE shared document, so an +/// uncapped oversized entry would inflate it past [`MAX_VAULT_SIZE_BYTES`] +/// and brick every wallet on reopen. Enforced with +/// [`SecretStoreError::SecretTooLarge`] at the write boundary before the +/// secret is sealed or inserted. +/// +/// # Why 8176 +/// +/// Secrets live in guarded, `mlock`ed pages (one allocation per secret, +/// never shared), so this ceiling also sets the process's worst-case +/// locked-memory demand. +/// +/// The value is a product ceiling, not page arithmetic: 8176 bytes is +/// ~30× the largest legitimate secret — a 24-word BIP-39 mnemonic is +/// under 256 bytes, a BIP-32 seed is 64, an xpriv ~112 — while still +/// costing a single guarded page. A security ceiling wants to be the +/// smallest that comfortably covers real use, so the slack between 8176 +/// and the 16368 that would also fit one page is deliberately left +/// unspent: on a 4 KiB-page host, where `memsec` rounds to the real page +/// size, 16368 would cost four locked pages per secret instead of one. +/// +/// The deepest path is a **`File`-arm [`reprotect`]**, not a read: it runs +/// a full read and then a rewrap while the caller holds *two* object +/// passwords. Its peak is the instant the AEAD open allocates the +/// plaintext, with these buffers live at once: +/// +/// | live buffer | payload | locked | +/// |---|---|---| +/// | stored envelope (`MAX_SECRET_LEN`) | 8176 B | 16 KiB | +/// | derived unwrap key | 32 B | 16 KiB | +/// | decrypted plaintext ([`MAX_PLAINTEXT_LEN`]) | 8064 B | 16 KiB | +/// | resident vault AEAD key | 32 B | 16 KiB | +/// | vault passphrase ([`MAX_PASSPHRASE_LEN`]) | ≤ 4080 B | 16 KiB | +/// | `current` object password ([`MAX_PASSPHRASE_LEN`]) | ≤ 4080 B | 16 KiB | +/// | `new` object password ([`MAX_PASSPHRASE_LEN`]) | ≤ 4080 B | 16 KiB | +/// +/// `memsec` locks `page_round(16 + payload)`, the 16 bytes being its +/// canary, and every ceiling above plus that canary fits inside one +/// `ASSUMED_PAGE_SIZE` page. So the budget is a count of live secrets, +/// not a sum of their sizes: seven pages, **112 KiB**. A concurrent +/// max-size read adds three more (its own envelope, key and plaintext: +/// 48 KiB), for 160 KiB against a 256 KiB budget — 96 KiB clear. +/// +/// The rewrap half of the same call peaks lower, because [`reprotect`] +/// scopes the old envelope away before allocating the new one and +/// `wrap_with_params` scopes its derived key away before encoding. Both +/// scopes remain load-bearing: each one held open would add a whole page +/// to the peak, since at this page size every live secret costs one +/// regardless of how few bytes it holds. +/// `store::tests::file_reprotect_peak_matches_the_documented_budget` +/// measures the peak rather than trusting this table. +/// +/// Every row is enforced, not assumed: the first three by this constant +/// and [`MAX_PLAINTEXT_LEN`], the passphrase rows by +/// [`MAX_PASSPHRASE_LEN`] on both the enrol and the read side. Raising +/// this constant past 16368 eats the budget two pages at a time, since +/// the envelope and its plaintext are both live. +/// +/// [`MAX_PLAINTEXT_LEN`]: crate::secrets::MAX_PLAINTEXT_LEN +/// [`MAX_PASSPHRASE_LEN`]: crate::secrets::MAX_PASSPHRASE_LEN +/// [`reprotect`]: crate::secrets::SecretStore::reprotect +pub const MAX_SECRET_LEN: usize = 8176; + +/// The budget table documented at [`MAX_SECRET_LEN`], made executable. /// -/// One file, one passphrase, one lock — the whole store rotates -/// together via [`rekey`](Self::rekey). Every [`SecretString`] and the -/// resident derived AEAD key are zeroized when the store drops. -/// The plaintext entry map is held in -/// [`EntryBody`]-shaped form: the bytes inside `ciphertext` are -/// ciphertext, but the structure is fully populated so reads do not -/// re-touch disk. +/// A future edit to any of the three ceilings that pushes the deepest +/// path past a constrained `RLIMIT_MEMLOCK` fails the build instead of +/// silently degrading every secret to swappable (the failure is +/// fail-open, so nothing else would notice). /// -/// The handle is cheap-`Clone` — both clones share the same -/// `Arc>`, so every operation through any clone sees the same -/// resident state and serializes against every other operation. +/// This half covers the CEILINGS. The other half of the same guarantee is +/// the host: `locked_cost` is denominated in 16 KiB pages while `memsec` +/// rounds to the kernel's runtime page size, so +/// [`verify_host_page_size`] rejects a larger-paged host at store +/// construction rather than let this table quietly describe nobody. +const _: () = { + use super::guarded::{locked_cost, ASSUMED_PAGE_SIZE}; + + /// The `RLIMIT_MEMLOCK` this crate budgets against. + /// + /// The smallest power of two that holds the derivation below with + /// real headroom. Deliberately far under what hosts actually grant — + /// systemd has defaulted `DefaultLimitMEMLOCK` to 8 MiB for years, + /// and a default Docker container inherits it — so the 64 KiB this + /// crate once budgeted against described no supported host. Nothing + /// reads the real limit at run time; see `SECRETS.md`. + const MEMLOCK_BUDGET: usize = 256 * 1024; + /// `File`-arm `reprotect`, at the instant the AEAD open allocates. + const REPROTECT_PEAK: usize = locked_cost(MAX_SECRET_LEN) + + locked_cost(32) + + locked_cost(crate::secrets::MAX_PLAINTEXT_LEN) + + locked_cost(32) + + 3 * locked_cost(crate::secrets::MAX_PASSPHRASE_LEN); + /// A max-size read racing it, sharing the resident rows. + const CONCURRENT_READ: usize = locked_cost(MAX_SECRET_LEN) + + locked_cost(32) + + locked_cost(crate::secrets::MAX_PLAINTEXT_LEN); + + assert!( + locked_cost(MAX_SECRET_LEN) == ASSUMED_PAGE_SIZE, + "a max-size envelope must still fit a single guarded page" + ); + assert!( + locked_cost(crate::secrets::MAX_PASSPHRASE_LEN) == ASSUMED_PAGE_SIZE, + "a max-size passphrase must still fit a single guarded page" + ); + assert!( + REPROTECT_PEAK == 7 * ASSUMED_PAGE_SIZE, + "the documented seven-page peak no longer matches the constants" + ); + assert!( + REPROTECT_PEAK + CONCURRENT_READ <= MEMLOCK_BUDGET, + "the deepest path plus one concurrent read must fit RLIMIT_MEMLOCK" + ); +}; + +/// A passphrase-encrypted file-backed credential store. +/// +/// One file, one passphrase, one lock; the whole store rotates together +/// via [`rekey`](Self::rekey). The cheap-`Clone` handle shares one +/// `Arc>`, so every clone sees the same resident state and +/// serializes against every other operation. All [`SecretString`]s and +/// the resident AEAD key are zeroized on drop. #[derive(Clone)] pub struct EncryptedFileStore { inner: Arc>, + /// Shared clone of [`EncryptedFileStoreInner::durability_uncertain`] so + /// the pollable accessor reads it WITHOUT taking the state lock. + durability_uncertain: Arc, + /// Argon2 params this store DERIVES at: the header written by a fresh + /// vault or a [`rekey`](Self::rekey), and the per-secret Tier-2 wrap + /// (via [`kdf_params`](Self::kdf_params)). Always + /// [`KdfParams::default_target`] except on a mock store + /// ([`open_mock`](Self::open_mock)), which floors it (#4111). + /// + /// Read-only after open and `Copy`, so `rekey` reads it without taking + /// the state lock — preserving its derive-outside-the-lock property. + /// It does NOT describe an already-existing vault: unlocking one always + /// re-derives under the params in ITS header. + kdf: KdfParams, } -/// All resident state for the store — path, advisory file lock, -/// in-memory vault, cached AEAD key, and the store-wide passphrase — -/// coalesced behind a single [`Mutex`] at the [`Arc`] wrapper level so -/// every mutation observes a consistent triple (vault matches the key -/// it was sealed under, key matches the passphrase that derived it). -/// -/// A single lock keeps put/get/delete/rekey serialized against each -/// other so a concurrent put cannot seal under an old key while rekey -/// is swapping in a new one. The disk write happens while -/// the lock is held; the file-lock sidecar already serializes -/// cross-process so this does not introduce a new I/O contention -/// point. +/// Resident store state behind a single [`Mutex`] so every mutation sees +/// a consistent triple (vault matches the key it was sealed under, key +/// matches the passphrase that derived it). The single lock serializes +/// put/get/delete/rekey so a put cannot seal under an old key mid-rekey; +/// the disk write happens under it, and the file-lock sidecar already +/// serializes cross-process, so this adds no new I/O contention point. struct EncryptedFileStoreInner { /// Vault file path supplied by the caller at [`open`]. /// /// [`open`]: EncryptedFileStore::open path: PathBuf, - /// In-memory vault. Mutations edit this directly and then call - /// `sync_to_disk` to re-encrypt and atomically replace the - /// on-disk file. Reads return clones from here without hitting - /// disk. + /// In-memory vault. Mutations edit it then `sync_to_disk` to + /// re-encrypt and atomically replace the file; reads clone from here. vault: Vault, - /// Cached AEAD key derived once at [`open`] from the salt + KDF - /// params + passphrase. Re-derived only on [`rekey`]. Keeping the - /// key resident is what makes mutations cheap (one AEAD seal per - /// entry, no Argon2 per op) and matches the resident-vault model. - /// A3 (key resident while unlocked) is an accepted threat in the - /// module docs; the buffer zeroizes when the state drops. + /// AEAD key derived once at [`open`] (re-derived only on [`rekey`]). + /// Held resident to avoid per-op Argon2 — A3 (key resident while + /// unlocked) is an accepted threat; zeroized when the state drops. /// /// [`open`]: EncryptedFileStore::open /// [`rekey`]: EncryptedFileStore::rekey derived_key: SecretBytes, - /// The store-wide passphrase. Swapped atomically with `vault` and - /// `derived_key` under the same lock during [`rekey`]. + /// Store-wide passphrase, swapped with `vault` + `derived_key` under + /// the same lock during [`rekey`]. /// /// [`rekey`]: EncryptedFileStore::rekey passphrase: SecretString, - /// Holds the cross-platform advisory write-lock on `.lock` - /// for the entire lifetime of the store. Dropped (releasing the - /// flock / LockFileEx) when the store drops. + /// Count of vault writes whose data committed (atomic rename succeeded) + /// but whose parent-directory fsync could NOT be confirmed, so the + /// rename's durability across power loss is uncertain. Non-fatal by + /// design — the write still returns `Ok` and the data is visible — this + /// is a pollable signal, not a rollback trigger (see + /// [`EncryptedFileStore::durability_uncertain_count`]). Bumped under the + /// state lock from `sync_to_disk`; read lock-free via the shared `Arc`. + durability_uncertain: Arc, + /// Holds the advisory write-lock on `.lock` for the store's + /// lifetime; dropped (releasing the lock) when the store drops. _lock: VaultLock, } impl EncryptedFileStore { - /// Open a vault store at `path`, unlocked by `passphrase`. `path` - /// is the vault FILE, not a directory — the operator picks the - /// filename. + /// Open a vault store at `path` (the vault FILE, not a directory), + /// unlocked by `passphrase`. /// - /// The call acquires an exclusive advisory lock on a sibling - /// `.lock` sidecar before touching the vault. If the lock is - /// already held (by another handle in this process or by another - /// process) the call returns [`SecretStoreError::AlreadyLocked`] - /// immediately — there is no retry loop. - /// - /// If `path` does not exist yet a fresh vault (random salt, default - /// Argon2 params, sealed verify token, no entries) is created at - /// `0600` on Unix. If it exists the vault is read, the passphrase - /// is verified against the header verify-token, and the plaintext - /// entry map is loaded into memory. Either way the returned store - /// is immediately usable. + /// Acquires an exclusive advisory lock on a sibling `.lock` + /// first; if already held, returns [`SecretStoreError::AlreadyLocked`] + /// immediately (no retry). A missing `path` is created fresh (random + /// salt, default Argon2 params, sealed verify token) at `0600` on + /// Unix; an existing one is read and its passphrase verified against + /// the header verify-token. Either way the returned store is usable. pub fn open( path: impl AsRef, passphrase: SecretString, ) -> Result { - let path = path.as_ref().to_path_buf(); - - // Make sure the parent directory exists so both the lock sidecar - // open and the vault create do not fail on a not-yet-materialized - // dir (canonical for first-setup operators). `Path::parent()` - // returns `Some("")` for a bare relative filename, which neither - // `create_dir_all` nor the cross-platform persist path can - // consume — normalize the empty-string parent to ".". + // Reject an out-of-range passphrase before touching the + // filesystem. A deliberate keyless vault uses + // `open_unprotected` instead. + validate_passphrase(&passphrase)?; + Self::open_inner(path.as_ref(), passphrase, KdfParams::default_target()) + } + + /// [`open`](Self::open), but fresh-vault creation and every per-secret + /// Tier-2 wrap use the enforced FLOOR instead of the shipped 64 MiB target + /// — the fastest configuration the crate's own bounds check still accepts. + /// An existing vault still unlocks under the parameters in its header. The + /// floor also applies inside + /// [`SecretStore::set_secret`](crate::secrets::SecretStore::set_secret) / + /// [`reprotect`](crate::secrets::SecretStore::reprotect). + /// + /// **Test-only.** A downstream suite that drives real end-to-end + /// `SecretStore` flows otherwise pays a production-strength KDF per call + /// (#4111). The resulting vault is REAL — same formats, same AAD + /// binding, same fail-closed reads — merely cheap to attack. Never build + /// one over a vault that holds live secrets. + /// + /// Two independent gates keep it out of production: + /// 1. compile-time — the `test-util` feature (or `cfg(test)`); + /// 2. runtime — the panic inherited from `KdfParams::floor_target`, in + /// case feature unification switches `test-util` on for a release build. + /// + /// # Panics + /// + /// Panics unless the build has `debug_assertions` on, or is this crate's + /// own test harness — the guard lives on `KdfParams::floor_target`, the + /// single choke point for weak-but-legal params, and fires before this + /// constructor touches the filesystem. `cargo test --release` on a + /// DOWNSTREAM crate is indistinguishable from a leaked release build and + /// panics too; such a suite must keep `debug-assertions = true`. + /// + /// An EXISTING vault is still unlocked under the params in its own header + /// — a mock store cannot make a production vault cheap to open, and + /// [`rekey`](Self::rekey) cannot either: it derives at the stronger of the + /// floor and the header it opened, so the floor applies only to params it + /// would raise. + #[cfg(any(test, feature = "test-util"))] + pub fn open_mock( + path: impl AsRef, + passphrase: SecretString, + ) -> Result { + // Ahead of every other check, so a rejected passphrase cannot mask the + // release-build panic behind a plain `Err`. + let kdf = KdfParams::floor_target(); + validate_passphrase(&passphrase)?; + Self::open_inner(path.as_ref(), passphrase, kdf) + } + + /// Open (or create) a **deliberately keyless** vault — the only door + /// that accepts no passphrase. The vault key is derived from an empty + /// passphrase under the public salt, so this is **obfuscation, not + /// confidentiality or authenticity**: anyone who can write the file can + /// forge a valid vault and inject a chosen unprotected secret. Use it only + /// where the stored secrets carry their own Tier-2 object password, or as + /// a staging step before + /// [`rekey`](Self::rekey) to a real passphrase. This is the explicit + /// keyless door, distinct from [`open`](Self::open), which enforces the + /// passphrase length floor. + pub fn open_unprotected(path: impl AsRef) -> Result { + Self::open_inner( + path.as_ref(), + SecretString::empty(), + KdfParams::default_target(), + ) + } + + /// Shared open/create core for [`open`](Self::open), + /// [`open_unprotected`](Self::open_unprotected) and + /// [`open_mock`](Self::open_mock). Does not apply the passphrase-length + /// guard — the public doors decide that. `kdf` is the params a FRESH + /// vault is created under; an existing one keeps its own header. + /// + /// # Errors + /// + /// [`SecretStoreError::HostPageSizeExceedsBudget`] before any other + /// check, if the host cannot honour the locked-memory budget below. + fn open_inner( + path: &Path, + passphrase: SecretString, + kdf: KdfParams, + ) -> Result { + // Ahead of the filesystem work: a store that cannot keep the + // budget documented at `MAX_SECRET_LEN` must not come into + // existence, and must not leave a fresh vault file behind either. + verify_host_page_size()?; + + let path = path.to_path_buf(); + + // Materialize the parent so the lock-sidecar open and vault + // create do not fail on a not-yet-existing dir. let parent = normalized_parent(&path); create_parent_dir(parent)?; + // Refuse unsafe permissions or ownership anywhere above the vault: + // an attacker who can replace an ancestor can replace the 0600 file. + crate::parent_permissions::check_parent_perms(parent).map_err(|error| match error { + crate::parent_permissions::ParentPermissionsError::Io(source) => { + SecretStoreError::io_at(parent, source) + } + crate::parent_permissions::ParentPermissionsError::Insecure { ancestor, reason } => { + SecretStoreError::InsecureParentDir { ancestor, reason } + } + })?; - // Acquire the lock first — every subsequent step assumes - // exclusive ownership of the vault file. + // Lock first — every subsequent step assumes exclusive ownership. let lock = VaultLock::acquire(&lock_path_for(&path))?; - // Decide between load-existing and create-fresh based on a - // single open attempt: NotFound → fresh; anything else → load - // (the perm check inside `read_existing_vault` covers loose - // perms on a real file). + // Built before the create branch so the initial-create write bumps + // the same counter every later write does — keeping + // `durability_uncertain_count`'s "0 == all writes confirmed durable" + // contract honest for the create path too. + let durability_uncertain = Arc::new(AtomicU64::new(0)); + + // NotFound → create fresh; anything else → load. let (vault, derived_key) = match Self::load_existing_vault(&path, &passphrase)? { Some(loaded) => loaded, - None => Self::create_new_vault(&path, &passphrase)?, + None => Self::create_new_vault(&path, &passphrase, kdf, &durability_uncertain)?, }; Ok(Self { @@ -192,11 +399,34 @@ impl EncryptedFileStore { vault, derived_key, passphrase, + durability_uncertain: Arc::clone(&durability_uncertain), _lock: lock, })), + durability_uncertain, + kdf, }) } + /// The Argon2 params this store derives at — see [`kdf`](Self::kdf). + /// The Tier-2 wrap in [`SecretStore`](crate::secrets::SecretStore) reads + /// it so a mock store's per-secret seals are floored too, with no new + /// parameter on any public method. + pub(crate) fn kdf_params(&self) -> KdfParams { + self.kdf + } + + /// Number of vault writes whose data committed but whose parent-directory + /// fsync could not be confirmed (rename durability across power loss is + /// uncertain). Monotonic, process-lifetime; `0` means every write this + /// store performed was confirmed durable. This is the **observable signal** + /// behind the intentionally non-fatal handling: such a write still returns + /// `Ok` (data committed + visible), so a caller that cares about hard + /// durability polls this rather than seeing a spurious error it would + /// otherwise roll back. Read lock-free. + pub fn durability_uncertain_count(&self) -> u64 { + self.durability_uncertain.load(Ordering::Relaxed) + } + /// Load and decrypt an existing vault file, returning `Ok(None)` if /// the file does not exist. Verifies the passphrase against the /// header verify-token before returning. @@ -211,43 +441,50 @@ impl EncryptedFileStore { Ok(Some((vault, key))) } - /// Build a brand-new empty vault, persist it at `0600`, and return - /// the in-memory state + derived key. + /// Build a brand-new empty vault under `kdf`, persist it at `0600`, and + /// return the in-memory state + derived key. fn create_new_vault( path: &Path, passphrase: &SecretString, + kdf: KdfParams, + durability_uncertain: &AtomicU64, ) -> Result<(Vault, SecretBytes), SecretStoreError> { - let (vault, key) = build_fresh_vault(passphrase)?; - write_vault_at(path, &vault)?; + let (vault, key) = build_fresh_vault(passphrase, kdf)?; + write_vault_at(path, &vault, Some(durability_uncertain))?; Ok((vault, key)) } - /// Re-encrypt the whole store under `new_passphrase`: fresh salt + - /// fresh per-entry nonces for every wallet's entries, then - /// atomically replace the vault file. No `.bak` retains old key - /// material. The swap is whole-store: every - /// wallet's entries are re-keyed in one shot, so the store cannot - /// end up half-rotated. The in-memory vault, derived key, and - /// passphrase advance together under the resident-state mutex. + /// Re-encrypt the whole store under `new_passphrase` — fresh salt and + /// per-entry nonces for every wallet, atomically in one shot (no + /// half-rotated state, no `.bak` retaining old key material). Vault, + /// derived key, and passphrase advance together under the mutex. /// - /// The fresh KDF / Argon2 derivation runs OUTSIDE the lock — it - /// only touches the new passphrase + a fresh salt and never reads - /// resident state, so paying ~hundreds of ms inside the critical - /// section would just stall unrelated put/get operations. + /// The Argon2 derivation runs OUTSIDE the lock — it touches only the + /// new passphrase + fresh salt, so paying ~hundreds of ms inside the + /// critical section would needlessly stall unrelated put/get ops. + /// + /// The vault is one shared fault domain: a corrupt entry blocks rekeying + /// every wallet in the vault until that entry is manually removed. + /// + /// The replacement header derives at this handle's own Argon2 target, which + /// for every non-test handle is [`KdfParams::default_target`] — the same + /// value a fresh vault gets. Rotating a passphrase therefore lands the + /// vault on the shipped parameters of the build doing the rotation. pub fn rekey(&self, new_passphrase: SecretString) -> Result<(), SecretStoreError> { - let (new_vault, new_key) = build_fresh_vault(&new_passphrase)?; + // Rekey always advances to a passphrase meeting the same bounds as + // open. Rejection leaves the resident and on-disk vault unchanged. + validate_passphrase(&new_passphrase)?; + // Derive OUTSIDE the lock: it touches only the new passphrase and a + // fresh salt, so paying hundreds of ms inside the critical section + // would stall unrelated put/get ops for nothing. + let (new_vault, new_key) = build_fresh_vault(&new_passphrase, self.kdf)?; lock_inner(&self.inner).rekey(new_vault, new_key, new_passphrase) } /// Store `secret` under `(wallet_id, label)`, returning the typed - /// [`SecretStoreError`] (lossless — no `keyring_core::Error` seam). - /// The public [`SecretStore`](crate::secrets::SecretStore) file - /// arm delegates here so the structural error distinction - /// survives. Symmetric with [`get_bytes`]: the secret stays - /// wrapped in [`SecretBytes`] across this seam; the lone bare-buffer - /// exposure lives one layer down at the AEAD seal call. - /// - /// [`get_bytes`]: Self::get_bytes + /// [`SecretStoreError`] losslessly (no SPI seam). The secret stays + /// wrapped across this boundary; the bare-buffer exposure is one layer + /// down at the AEAD seal. pub(crate) fn put_bytes( &self, wallet_id: &WalletId, @@ -258,12 +495,9 @@ impl EncryptedFileStore { } /// Retrieve the plaintext under `(wallet_id, label)`, or `None` if - /// absent, returning the typed [`SecretStoreError`]. The plaintext - /// stays inside a zeroizing [`SecretBytes`] all the way to this - /// boundary; the single `.expose_secret().to_vec()` conversion lives - /// at the upstream `CredentialApi::get_secret` - /// SPI seam, the only point where the SPI contract demands a bare - /// `Vec`. + /// absent. The plaintext stays inside a zeroizing [`SecretBytes`] to + /// this boundary; the bare-`Vec` conversion lives only at the + /// `CredentialApi::get_secret` SPI seam, where the contract demands it. pub(crate) fn get_bytes( &self, wallet_id: &WalletId, @@ -282,6 +516,26 @@ impl EncryptedFileStore { lock_inner(&self.inner).delete(wallet_id, label) } + /// Atomic read-modify-write of `(wallet_id, label)`. Holds the store lock + /// across the read → `transform` → write so a concurrent `put`/`delete` + /// can't interleave and let a transform built on stale bytes clobber a + /// newer value. `transform` receives the currently-stored bytes (`None` + /// if absent) and returns the bytes to persist. + pub(crate) fn reprotect_bytes( + &self, + wallet_id: &WalletId, + label: &str, + transform: F, + ) -> Result<(), SecretStoreError> + where + F: FnOnce(Option) -> Result, + { + let mut inner = lock_inner(&self.inner); + let current = inner.get(wallet_id, label)?; + let next = transform(current)?; + inner.put(wallet_id, label, &next) + } + #[cfg(test)] pub(crate) fn test_read_vault_from_disk(&self) -> Result, SecretStoreError> { read_vault_at(&lock_inner(&self.inner).path) @@ -289,14 +543,12 @@ impl EncryptedFileStore { #[cfg(test)] pub(crate) fn test_write_vault_to_disk(&self, vault: &Vault) -> Result<(), SecretStoreError> { - write_vault_at(&lock_inner(&self.inner).path, vault) + write_vault_at(&lock_inner(&self.inner).path, vault, None) } - /// Drop the in-memory copy of the vault and reload it from disk - /// under the current passphrase. Useful for tests that mutate the - /// on-disk file out from under the store and want subsequent reads - /// to observe the new bytes (the resident-vault model otherwise - /// caches the loaded state). + /// Reload the vault from disk under the current passphrase, so a test + /// that patched the on-disk file sees the new bytes (the resident + /// model otherwise serves the cached state). #[cfg(test)] pub(crate) fn test_reload_from_disk(&self) -> Result<(), SecretStoreError> { let mut state = lock_inner(&self.inner); @@ -310,11 +562,9 @@ impl EncryptedFileStore { } } -/// Acquire the single coarse-grained state lock on `inner`. -/// Poisoned-mutex recovery is "log and continue": a previously-panicked -/// holder cannot have left the [`EncryptedFileStoreInner`] half-written -/// (every mutation either succeeds wholesale and writes to disk or -/// reverts), so the inner value is safe to keep using. +/// Acquire the state lock on `inner`. A poisoned mutex is recovered (not +/// propagated): every mutation either commits wholesale or reverts, so a +/// panicked holder cannot have left the inner value half-written. fn lock_inner( inner: &Arc>, ) -> std::sync::MutexGuard<'_, EncryptedFileStoreInner> { @@ -325,7 +575,7 @@ impl EncryptedFileStoreInner { /// Re-encrypt the resident vault and atomically replace the /// on-disk file. Runs inside the state-lock critical section. fn sync_to_disk(&self) -> Result<(), SecretStoreError> { - write_vault_at(&self.path, &self.vault) + write_vault_at(&self.path, &self.vault, Some(&self.durability_uncertain)) } /// In-place seal + disk-write for [`EncryptedFileStore::put_bytes`]; @@ -337,32 +587,38 @@ impl EncryptedFileStoreInner { secret: &SecretBytes, ) -> Result<(), SecretStoreError> { let label = validated_label(label)?.to_string(); + // Reject before sealing: the shared document would otherwise + // inflate past the read-side ceiling and brick every wallet. + if secret.len() > MAX_SECRET_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: secret.len(), + max: MAX_SECRET_LEN, + }); + } let aad = format::aad(format::FORMAT_VERSION, wallet_id.as_bytes(), &label); let (nonce, ciphertext) = crypto::seal(&self.derived_key, &aad, secret.expose_secret())?; - // Mutate in memory; remember the prior body so we can roll - // back on a disk-write failure (the resident state must - // always match what is on disk after a returned-Ok mutation). + // Remember the prior body so a disk-write failure can revert the + // resident state to match disk (Ok must imply memory == disk). let prior = { let entries = self.vault.wallets.entry(wallet_id.to_hex()).or_default(); entries.insert(label.clone(), EntryBody { nonce, ciphertext }) }; if let Err(e) = self.sync_to_disk() { - let entries = self - .vault - .wallets - .get_mut(&wallet_id.to_hex()) - .expect("entry just inserted"); - match prior { - Some(prev) => { - entries.insert(label, prev); - } - None => { - entries.remove(&label); - if entries.is_empty() { - self.vault.wallets.remove(&wallet_id.to_hex()); + // A missing bucket means the insert never landed (nothing to + // undo) — return the error rather than panic. + if let Some(entries) = self.vault.wallets.get_mut(&wallet_id.to_hex()) { + match prior { + Some(prev) => { + entries.insert(label, prev); + } + None => { + entries.remove(&label); + if entries.is_empty() { + self.vault.wallets.remove(&wallet_id.to_hex()); + } } } } @@ -457,9 +713,8 @@ impl EncryptedFileStoreInner { new_vault.wallets.insert(wallet_hex.clone(), new_entries); } - // Stage the new triple in memory, write to disk, and on - // failure restore the old triple so the live handle keeps - // serving under the still-on-disk key. + // Stage the new triple; on disk-write failure restore the old one + // so the live handle keeps serving under the still-on-disk key. let old_vault = std::mem::replace(&mut self.vault, new_vault); let old_key = std::mem::replace(&mut self.derived_key, new_key); let old_pp = std::mem::replace(&mut self.passphrase, new_passphrase); @@ -476,53 +731,72 @@ impl EncryptedFileStoreInner { impl Drop for EncryptedFileStoreInner { fn drop(&mut self) { - // Belt-and-suspenders sync of resident state. Eager-sync on - // every mutation makes this redundant in the success path, but - // a final write lets a future feature (e.g. opportunistic - // background buffering) hang off the same Drop without changing - // the contract. `&mut self` here implies unique ownership — - // the outer `Mutex` is being dropped too, so no other holder - // can be waiting. - if let Err(e) = self.sync_to_disk() { + // Best-effort final sync. Redundant in the success path (every + // mutation eager-syncs) but kept as a contract anchor. Calls the + // non-logging `do_write_vault_at` so this drop-context `warn!` is the + // sole log line, not a second one behind `write_vault_at`'s own inner + // warn (which still fires for the put/delete/rekey paths). + if let Err(e) = do_write_vault_at(&self.path, &self.vault, Some(&self.durability_uncertain)) + { tracing::warn!(error = %e, "drop-time vault sync failed"); } - // Re-assert restrictive perms on Unix. Between writes the file - // is already 0600, but this defends against a peer that - // loosened them through some other path while we held the - // lock. Best-effort: any failure is non-fatal at Drop. + // Re-assert 0600 on Unix in case a peer loosened it while we held + // the lock. Best-effort: failures are non-fatal at Drop. #[cfg(unix)] - if let Ok(file) = open_no_follow(&self.path) { - if let Err(e) = set_restrictive_perms(&file) { - tracing::warn!(error = %e, "drop-time perm re-assert failed"); + match open_no_follow(&self.path) { + Ok(file) => { + if let Err(e) = set_restrictive_perms(&file) { + tracing::warn!(error = %e, "drop-time perm re-assert failed"); + } + } + Err(e) => { + tracing::warn!( + error = %e, + "drop-time perm re-assert skipped: vault re-open refused" + ); } } - // The `VaultLock` field drops naturally after this method - // returns, releasing the OS advisory lock. + // `VaultLock` drops after this returns, releasing the OS lock. } } -/// Sidecar advisory-lock path for the store's vault file. Kept -/// distinct from the vault file itself so the cross-platform -/// `persist` swap never touches the inode an open lock fd points -/// at — the lock fd remains valid across the atomic replace. +/// Sidecar lock path (`.lock`). Distinct from the vault file so the +/// atomic `persist` swap never touches the inode the lock fd points at. fn lock_path_for(path: &Path) -> PathBuf { let mut s = path.to_path_buf().into_os_string(); s.push(".lock"); PathBuf::from(s) } -/// Build a fresh vault skeleton: random salt, default Argon2 -/// params, and a passphrase-verification token sealed under the -/// freshly derived key (the token is the mixed-key-corruption guard). -/// Returns the (entry-less) vault and the -/// derived key so the caller can seal entries against it without -/// re-deriving. -fn build_fresh_vault(passphrase: &SecretString) -> Result<(Vault, SecretBytes), SecretStoreError> { +/// Reject a passphrase outside the accepted length range: below the +/// post-trim floor, or past the one-guarded-page ceiling that keeps the +/// resident passphrase a bounded row in [`MAX_SECRET_LEN`]'s budget. +fn validate_passphrase(passphrase: &SecretString) -> Result<(), SecretStoreError> { + if passphrase.is_below_minimum_passphrase_len() { + return Err(SecretStoreError::BlankPassphrase); + } + if passphrase.exceeds_maximum_passphrase_len() { + return Err(SecretStoreError::PassphraseTooLong { + found: passphrase.len(), + max: MAX_PASSPHRASE_LEN, + }); + } + Ok(()) +} + +/// Build a fresh entry-less vault (random salt, `kdf` Argon2 params, +/// verify-token sealed under the derived key) plus that derived key, so +/// the caller can seal entries without re-deriving. `kdf` lands in the +/// header AND in the verify-token's AAD, so the params stay tamper-bound +/// whichever value the caller picked. +fn build_fresh_vault( + passphrase: &SecretString, + kdf: KdfParams, +) -> Result<(Vault, SecretBytes), SecretStoreError> { let mut salt = [0u8; SALT_LEN]; crypto::random_bytes(&mut salt)?; - let kdf = KdfParams::default_target(); let key = crypto::derive_key(passphrase, &salt, kdf)?; - let v_aad = format::verify_aad(format::FORMAT_VERSION); + let v_aad = format::verify_aad(format::FORMAT_VERSION, &salt, &kdf); let (verify_nonce, verify_ct) = crypto::seal(&key, &v_aad, format::VERIFY_CONSTANT)?; Ok(( Vault { @@ -538,15 +812,32 @@ fn build_fresh_vault(passphrase: &SecretString) -> Result<(Vault, SecretBytes), } /// Derive the key from `passphrase` and verify it against the vault's -/// token *before* any entry is touched. A wrong passphrase fails the -/// token's AEAD tag (constant-time) and yields `WrongPassphrase` with -/// no plaintext. +/// token *before* any entry is touched. An authentication failure means a +/// wrong passphrase OR an edited header; both yield `WrongPassphrase` with no +/// plaintext, because the two are cryptographically indistinguishable here — +/// the header's `kdf` and `salt` feed both the derived key and the +/// verify-token AAD, so tampering with either fails the tag exactly as a wrong +/// passphrase does. +/// +/// The header's Argon2 params are bounded by `KdfParams::enforce_bounds`, +/// which `crypto::derive_key` runs BEFORE touching the allocator. That band +/// (`ARGON2_MIN_M_KIB..=ARGON2_MAX_M_KIB`, 19 MiB..=1 GiB) is deliberately far +/// wider than the shipped `default_target()`, and the width buys VERSION +/// TOLERANCE, not tunability: every header this crate writes carries +/// `default_target()` (or, under `test-util`, `floor_target()`), so the only +/// headers the extra width admits are those written by a build whose default +/// differed. Clamping reads to the current default would make those — and +/// every vault at all, were the default ever lowered — permanently unopenable. +/// +/// The Tier-2 envelope clamps its own reads +/// (`KdfParams::enforce_read_ceiling`) because an envelope's cost is paid on +/// every read by whoever holds the object password. Do not unify the two. fn derive_and_verify( vault: &Vault, passphrase: &SecretString, ) -> Result { let key = crypto::derive_key(passphrase, &vault.salt, vault.kdf)?; - let v_aad = format::verify_aad(format::FORMAT_VERSION); + let v_aad = format::verify_aad(format::FORMAT_VERSION, &vault.salt, &vault.kdf); match crypto::open(&key, &vault.verify_nonce, &v_aad, &vault.verify_ct) { Ok(_) => Ok(key), Err(SecretStoreError::Decrypt) => Err(SecretStoreError::WrongPassphrase), @@ -554,13 +845,11 @@ fn derive_and_verify( } } -/// Read + parse the vault at `path`, or `None` if it does not exist. -/// Refuses a pre-existing file with looser-than-0600 perms and a file -/// exceeding [`MAX_VAULT_SIZE_BYTES`]. -/// -/// Eliminates the metadata→read TOCTOU: opens the file once with -/// `O_NOFOLLOW` on Unix, then derives perms / size from -/// the open handle's `metadata()` and reads from the same fd. +/// Read + parse the vault at `path`, or `None` if absent. Refuses +/// looser-than-0600 perms, foreign ownership, and a file over +/// [`MAX_VAULT_SIZE_BYTES`]. +/// Opens once with `O_NOFOLLOW` and derives perms/size from the same fd +/// to avoid a metadata→read TOCTOU. fn read_vault_at(path: &Path) -> Result, SecretStoreError> { let file = match open_no_follow(path) { Ok(file) => file, @@ -570,7 +859,7 @@ fn read_vault_at(path: &Path) -> Result, SecretStoreError> { let meta = file .metadata() .map_err(|e| SecretStoreError::io_at(path, e))?; - check_perms(&meta)?; + check_perms(path, &meta)?; let len = meta.len(); if len > MAX_VAULT_SIZE_BYTES { return Err(SecretStoreError::VaultTooLarge { @@ -592,27 +881,36 @@ fn read_vault_at(path: &Path) -> Result, SecretStoreError> { Ok(Some(format::deserialize(&bytes)?)) } -/// Atomically replace the vault at `path`, cross-platform. -/// -/// Stages into a `NamedTempFile` in the SAME directory (so `persist` -/// cannot fail cross-volume), tightens perms to 0600 on Unix before -/// any byte is written, then: `write_all` → `sync_all` → -/// `persist(path)` → Unix parent-dir fsync. The destination is never -/// pre-removed, so a crash leaves either the old or the new vault, -/// never an absent one. On `persist` failure the temp drops and -/// self-cleans — no manual remove racing it. The temp holds only -/// ciphertext+header, never plaintext. -fn write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> { - do_write_vault_at(path, vault).inspect_err(|e| { +/// Atomically replace the vault at `path`. Stages into a same-directory +/// `NamedTempFile` (so `persist` cannot fail cross-volume), tightens to +/// 0600 before writing, then `write_all` → `sync_all` → `persist` → Unix +/// parent-dir fsync. The destination is never pre-removed, so a crash +/// leaves the old or new vault, never none. The temp holds only +/// ciphertext + header, never plaintext. +fn write_vault_at( + path: &Path, + vault: &Vault, + durability_uncertain: Option<&AtomicU64>, +) -> Result<(), SecretStoreError> { + do_write_vault_at(path, vault, durability_uncertain).inspect_err(|e| { tracing::warn!(error = %e, "failed to write vault file"); }) } -fn do_write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> { +fn do_write_vault_at( + path: &Path, + vault: &Vault, + durability_uncertain: Option<&AtomicU64>, +) -> Result<(), SecretStoreError> { let serialized = format::serialize(vault); - // Normalize an empty / bare-filename parent to "." so neither - // `NamedTempFile::new_in` nor the Unix parent-dir fsync sees an - // empty path. + // Defence in depth: never write a vault the read path would refuse, + // so the on-disk file is never left unopenable. + if serialized.len() as u64 > MAX_VAULT_SIZE_BYTES { + return Err(SecretStoreError::VaultTooLarge { + found: serialized.len() as u64, + max: MAX_VAULT_SIZE_BYTES, + }); + } let parent = normalized_parent(path); create_parent_dir(parent)?; let mut tmp = @@ -626,30 +924,59 @@ fn do_write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> .map_err(|e| SecretStoreError::io_at(path, e))?; tmp.persist(path) .map_err(|e| SecretStoreError::io_at(path, e.error))?; + // The vault is now committed on disk via the atomic persist() rename above. + // A subsequent parent-directory fsync failure cannot undo the already-written + // file. Propagating the error would force callers (put/delete/rekey) to roll + // back in-memory state that already matches the on-disk vault — diverging the + // live handle from disk. So this stays NON-FATAL: the write returns `Ok` and + // the data is committed + visible. We surface the unconfirmed power-loss + // durability two ways instead of swallowing it — a `warn!` log (the + // condition is degraded-but-recoverable, self-healing on the next + // confirmed write, NOT the fatal `error!` a propagated write failure + // gets) AND a bump of the pollable `durability_uncertain` counter + // ([`EncryptedFileStore::durability_uncertain_count`]). #[cfg(unix)] { - let d = fs::File::open(parent).map_err(|e| SecretStoreError::io_at(parent, e))?; - d.sync_all() - .map_err(|e| SecretStoreError::io_at(parent, e))?; + // INTENTIONAL(fsync-parent-dir-tolerant): a failed parent-dir fsync + // warns and counts instead of failing the write. Accepted risk: the + // vault's own fsync+rename already put the data on disk, so only the + // directory entry's durability across power loss is unconfirmed — + // surfaced to callers via `durability_uncertain_count()`. + let signal_unconfirmed = |e: &std::io::Error| { + tracing::warn!( + error = %e, + parent = %parent.display(), + "parent-dir fsync unconfirmed after vault persist; data is committed on disk \ + but its rename durability across power loss is NOT confirmed" + ); + if let Some(counter) = durability_uncertain { + counter.fetch_add(1, Ordering::Relaxed); + } + }; + match fs::File::open(parent) { + Ok(d) => { + if let Err(e) = d.sync_all() { + signal_unconfirmed(&e); + } + } + Err(e) => signal_unconfirmed(&e), + } } Ok(()) } -/// Normalize `path.parent()` for callers that need a directory path -/// they can pass to `fs::create_dir_all`, `NamedTempFile::new_in`, and -/// the Unix parent-dir fsync. `Path::parent()` returns `Some("")` for a -/// bare relative filename like `"vault.pwsvault"`, and the empty path -/// errors out at every one of those calls — normalize to "." so a -/// caller that supplies a bare filename in their cwd just works. +/// Normalize `path.parent()` to a usable directory: `Path::parent()` +/// returns `Some("")` for a bare filename, which errors at +/// `create_dir_all` / `NamedTempFile::new_in` / parent-dir fsync — map +/// the empty parent to "." so a bare filename in the cwd just works. fn normalized_parent(path: &Path) -> &Path { path.parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")) } -/// Create the parent directory for a vault file, applying a `0700` mode -/// on Unix so the directory created at first-setup is not -/// world-readable. Idempotent: a pre-existing directory is left alone. +/// Create the vault's parent directory at `0700` on Unix (not +/// world-readable). Idempotent — a pre-existing directory is left alone. fn create_parent_dir(parent: &Path) -> Result<(), SecretStoreError> { #[cfg(unix)] { @@ -657,41 +984,37 @@ fn create_parent_dir(parent: &Path) -> Result<(), SecretStoreError> { fs::DirBuilder::new() .mode(0o700) .recursive(true) - .create(parent)?; + .create(parent) + .map_err(|e| SecretStoreError::io_at(parent, e))?; } - // INTENTIONAL: Windows ACL hardening on the parent dir is deferred - // to https://github.com/dashpay/platform/issues/3754. The recursive - // create still runs so the path materializes; operators on Windows - // MUST tighten ACLs manually until the follow-up lands. + // INTENTIONAL: Windows parent-dir ACL hardening deferred to + // https://github.com/dashpay/platform/issues/3754 — tighten manually. #[cfg(not(unix))] { - fs::create_dir_all(parent)?; + fs::create_dir_all(parent).map_err(|e| SecretStoreError::io_at(parent, e))?; } Ok(()) } -/// Cross-platform advisory write-lock holder. Owns a `Box>` -/// (so the address is stable) and an owned `RwLockWriteGuard` borrowing -/// from it. Dropping the holder drops the guard first (which releases -/// the OS lock via `fd-lock`'s Drop impl, calling `flock(LOCK_UN)` on -/// Unix and `UnlockFileEx` on Windows) and then frees the heap-pinned -/// `RwLock`. -/// -/// The self-reference is unavoidable: `fd-lock`'s guard borrows the -/// `RwLock`, and the resident-vault model requires the lock to stay -/// held continuously between `open` and `Drop`. Wrapped in a small -/// allow-unsafe island so the rest of the crate keeps -/// `deny(unsafe_code)`. Safety arguments: +/// Advisory write-lock holder owning a heap-pinned `Box>` +/// and a self-referential `'static` guard borrowing from it. The +/// self-reference is unavoidable: `fd-lock`'s guard borrows the `RwLock`, +/// and the resident-vault model needs the lock held continuously between +/// `open` and `Drop`. Safety: /// -/// 1. The `RwLock` lives on the heap via `Box::into_raw`, so its -/// address is stable for the holder's lifetime. -/// 2. The `'static` lifetime on the guard is a lie tolerated only -/// because the guard never outlives the holder, and the holder's -/// `Drop` impl takes the guard out (running its Drop) *before* -/// reclaiming the box. +/// 1. `Box::into_raw` gives the `RwLock` a stable address for the +/// holder's lifetime. +/// 2. The `'static` guard lifetime is a lie sound only because `Drop` +/// takes the guard out (running its Drop, releasing the OS lock) +/// BEFORE reclaiming the box. /// 3. The raw pointer never escapes this module. +/// +/// Calibrated to `fd-lock = "=4.0.4"` (exact-pinned): any bump must +/// re-verify the guard releases the OS lock before the box is reclaimed. mod vault_lock { - #![allow(unsafe_code)] + // INTENTIONAL: the crate's only unsafe island; soundness rests on the + // drop-order argument above, not a Miri test. `#![deny(unsafe_code)]` + // still applies everywhere outside the narrowed per-item allows. use std::fs; use std::path::Path; @@ -709,26 +1032,36 @@ mod vault_lock { // member is a `File`/`RawFd`, both `Send + Sync`). The raw pointer // points at the heap-pinned `RwLock` this struct owns; sending the // struct moves ownership of the box address with it. + #[expect( + unsafe_code, + reason = "sole owner of the heap-pinned RwLock the raw pointer targets" + )] unsafe impl Send for VaultLock {} + #[expect( + unsafe_code, + reason = "sole owner of the heap-pinned RwLock the raw pointer targets" + )] unsafe impl Sync for VaultLock {} impl VaultLock { pub(super) fn acquire(lock_path: &Path) -> Result { - // INTENTIONAL: on non-unix platforms the symlink-following - // hardening is deferred to - // https://github.com/dashpay/platform/issues/3754 — Windows - // requires `FILE_FLAG_OPEN_REPARSE_POINT` via the raw API - // and is out of scope for the secrets-feature landing. + // INTENTIONAL: non-unix symlink hardening (Windows needs + // FILE_FLAG_OPEN_REPARSE_POINT) deferred to + // https://github.com/dashpay/platform/issues/3754. let mut opts = fs::OpenOptions::new(); opts.read(true).write(true).create(true).truncate(false); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; opts.custom_flags(libc::O_NOFOLLOW); + // Restrictive from the first byte — no loose-perm window. + opts.mode(0o600); } let lock_file = opts .open(lock_path) .map_err(|e| SecretStoreError::io_at(lock_path, e))?; + // `mode()` only applies to a file this call creates; re-assert + // 0600 on a pre-existing sidecar. #[cfg(unix)] set_restrictive_perms(&lock_file)?; @@ -740,6 +1073,10 @@ mod vault_lock { // at a valid `RwLock`. No other reference exists // yet, so promoting it to `&'static mut` is sound for the // borrow we hand to `try_write`. + #[expect( + unsafe_code, + reason = "sole reference to a fresh Box::into_raw allocation; no aliasing" + )] let static_ref: &'static mut fd_lock::RwLock = unsafe { &mut *raw }; let guard = match static_ref.try_write() { @@ -749,10 +1086,16 @@ mod vault_lock { // no live borrow points at the box; reclaiming // here is sound and avoids leaking on the error // path. - unsafe { drop(Box::from_raw(raw)) }; + #[expect( + unsafe_code, + reason = "guard never created, so no live borrow; reclaim the box" + )] + unsafe { + drop(Box::from_raw(raw)) + }; return Err(match e.kind() { std::io::ErrorKind::WouldBlock => SecretStoreError::AlreadyLocked, - _ => SecretStoreError::from(e), + _ => SecretStoreError::io_at(lock_path, e), }); } }; @@ -773,19 +1116,22 @@ mod vault_lock { // the guard has just been dropped (no live borrow), and we // are the only owner. Reclaiming the Box runs the // `RwLock`'s Drop, which closes the file fd. - unsafe { drop(Box::from_raw(self.rwlock)) }; + #[expect( + unsafe_code, + reason = "guard dropped first; sole owner reclaims the box" + )] + unsafe { + drop(Box::from_raw(self.rwlock)) + }; } } } use vault_lock::VaultLock; -/// Why a wallet-id hex string failed the canonical-form check. -/// -/// `WalletId::to_hex` only ever emits 64 lowercase hex chars, so every -/// seam that parses a wallet id back enforces exactly that shape in one -/// place via [`wallet_id_hex_to_bytes`]; this enum lets each caller map -/// the reason onto its own error type with the right message. +/// Why a wallet-id hex string failed the canonical-form check, so each +/// caller of [`wallet_id_hex_to_bytes`] can map the reason onto its own +/// error type and message. enum WalletIdHexError { /// Not exactly 64 characters. WrongLength, @@ -795,10 +1141,9 @@ enum WalletIdHexError { NotHex, } -/// Decode a 64-lowercase-hex-char wallet id into its 32 bytes, enforcing -/// the canonical form `WalletId::to_hex` writes (64 chars, lowercase). -/// The single seam both the on-disk outer-key check and the SPI -/// service-string parse go through so the contract lives in one place. +/// Decode a wallet id into 32 bytes, enforcing the canonical form +/// `WalletId::to_hex` writes (64 lowercase hex chars). The single seam +/// for both the on-disk outer-key check and the SPI service-string parse. fn wallet_id_hex_to_bytes(s: &str) -> Result<[u8; 32], WalletIdHexError> { if s.len() != 64 { return Err(WalletIdHexError::WrongLength); @@ -811,13 +1156,10 @@ fn wallet_id_hex_to_bytes(s: &str) -> Result<[u8; 32], WalletIdHexError> { Ok(out) } -/// Decode a wallet-id hex string (the on-disk outer key) into the -/// 32-byte form the AAD construction expects. A malformed key here is -/// an on-disk integrity failure — the format-layer parse already -/// constrains entries to JSON object semantics, but the outer key is -/// a free-form string at the type level, so the bytes-back check is a -/// defence-in-depth structural guard. Off-canonical (uppercase / wrong -/// length / non-hex) keys are all rejected as corruption. +/// Decode the on-disk outer-key wallet hex into the 32 bytes the AAD +/// expects. The outer key is a free-form string at the type level, so +/// this bytes-back check is a defence-in-depth structural guard; any +/// off-canonical key is rejected as corruption. pub(super) fn decode_wallet_id_hex(s: &str) -> Result<[u8; 32], SecretStoreError> { wallet_id_hex_to_bytes(s).map_err(|_| SecretStoreError::MalformedVault) } @@ -843,14 +1185,10 @@ fn parse_service(service: &str) -> Result { Ok(WalletId::from(bytes)) } -/// A `(wallet_id, label)` row in an [`EncryptedFileStore`]. -/// -/// Holds a [`Clone`]d handle to the parent store so each credential -/// goes through the same public store API (and the same single-lock -/// critical section per operation). All four operations re-validate -/// `user` (label); the store key is resident on the inner so a -/// wrong-passphrase race cannot happen at the credential layer — the -/// open already failed if the passphrase was wrong. +/// A `(wallet_id, label)` row in an [`EncryptedFileStore`]. Holds a +/// cloned handle to the parent so each op goes through the same store API +/// and single-lock critical section. All ops re-validate the label; the +/// passphrase was already verified at open, so no wrong-pass race here. pub struct EncryptedFileCredential { store: EncryptedFileStore, wallet_id: WalletId, @@ -868,7 +1206,14 @@ impl std::fmt::Debug for EncryptedFileCredential { impl CredentialApi for EncryptedFileCredential { fn set_secret(&self, secret: &[u8]) -> KeyringResult<()> { - let _ = validated_label(&self.label).map_err(SecretStoreError::from)?; + validated_label(&self.label).map_err(SecretStoreError::from)?; + // Cap before wrapping so an oversized secret is never materialized. + if secret.len() > MAX_SECRET_LEN { + return Err(KeyringError::from(SecretStoreError::SecretTooLarge { + found: secret.len(), + max: MAX_SECRET_LEN, + })); + } self.store .put_bytes( &self.wallet_id, @@ -879,8 +1224,10 @@ impl CredentialApi for EncryptedFileCredential { } fn get_secret(&self) -> KeyringResult> { - let _ = validated_label(&self.label).map_err(SecretStoreError::from)?; + validated_label(&self.label).map_err(SecretStoreError::from)?; match self.store.get_bytes(&self.wallet_id, &self.label) { + // SPI contract forces a bare Vec; caller owns disposal — + // prefer SecretStore::get for a zeroizing SecretBytes. Ok(Some(v)) => Ok(v.expose_secret().to_vec()), Ok(None) => Err(KeyringError::NoEntry), Err(e) => Err(e.into()), @@ -888,7 +1235,7 @@ impl CredentialApi for EncryptedFileCredential { } fn delete_credential(&self) -> KeyringResult<()> { - let _ = validated_label(&self.label).map_err(SecretStoreError::from)?; + validated_label(&self.label).map_err(SecretStoreError::from)?; match self.store.delete_bytes(&self.wallet_id, &self.label) { Ok(true) => Ok(()), Ok(false) => Err(KeyringError::NoEntry), @@ -921,6 +1268,11 @@ impl CredentialStoreApi for EncryptedFileStore { STORE_ID.to_string() } + /// Build a credential for `(service, user)`. SPI-direct consumers: + /// format the returned [`KeyringError`] with `Display`, never `Debug` + /// — byte-bearing variants embed raw bytes in `Debug` (CWE-209/ + /// CWE-532). Prefer the typed + /// [`SecretStore`](crate::secrets::SecretStore) path. fn build( &self, service: &str, @@ -950,10 +1302,9 @@ impl CredentialStoreApi for EncryptedFileStore { impl std::fmt::Debug for EncryptedFileStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // `try_lock` rather than `lock_inner` so a Debug invoked from - // a panic path while the same thread already holds the state - // lock cannot deadlock or double-panic. Poison is folded into - // the same fallback display as contention. + // `try_lock` (not `lock_inner`) so a Debug from a panic path that + // already holds the lock cannot deadlock; poison folds into the + // same fallback as contention. let path: PathBuf = match self.inner.try_lock() { Ok(guard) => guard.path.clone(), Err(_) => PathBuf::from(""), @@ -964,14 +1315,11 @@ impl std::fmt::Debug for EncryptedFileStore { } } -/// Project an entry-level `crypto::open` result into the typed -/// distinction the secret backend exposes. The verify-token has already -/// passed at every caller (open / get / rekey), so a -/// `SecretStoreError::Decrypt` here is corruption or tampering of the -/// individual entry — **not** a wrong passphrase. Logs the non-secret -/// `(wallet_id, label)` pair at error level (never the secret) and -/// maps to `SecretStoreError::Corruption`. Every other variant rides -/// through unchanged. +/// Map an entry-level `crypto::open` failure to the typed distinction. +/// The verify-token has already passed at every caller, so a `Decrypt` +/// here is entry corruption/tampering, NOT a wrong passphrase — logs the +/// non-secret `(wallet_id, label)` and maps to `Corruption`; other +/// variants pass through. fn entry_decrypt_or_corruption( wallet_hex: &str, label: &str, @@ -991,23 +1339,46 @@ fn entry_decrypt_or_corruption( } #[cfg(unix)] -fn check_perms(meta: &fs::Metadata) -> Result<(), SecretStoreError> { +fn check_perms(path: &Path, meta: &fs::Metadata) -> Result<(), SecretStoreError> { + check_perms_for_uid(path, meta, effective_uid()) +} + +#[cfg(unix)] +fn check_perms_for_uid( + path: &Path, + meta: &fs::Metadata, + effective_uid: u32, +) -> Result<(), SecretStoreError> { use std::os::unix::fs::MetadataExt; let mode = meta.mode() & 0o777; if mode & 0o077 != 0 { - return Err(SecretStoreError::InsecurePermissions { mode }); + return Err(SecretStoreError::InsecurePermissions { + path: path.to_path_buf(), + mode, + }); + } + if meta.uid() != effective_uid { + return Err(SecretStoreError::InsecureOwnership { + path: path.to_path_buf(), + found: meta.uid(), + expected: effective_uid, + }); } Ok(()) } -// INTENTIONAL: Windows ACL read-check deferred to a follow-up PR — -// tracked at https://github.com/dashpay/platform/issues/3754. Vault -// file mode hardening on Windows requires GetSecurityInfo via -// `windows-acl` or `winapi`; out of scope for the secrets-feature -// landing. Operators on Windows MUST set ACLs manually until the -// follow-up lands. +#[cfg(unix)] +#[expect(unsafe_code, reason = "libc geteuid requires an unsafe call")] +fn effective_uid() -> u32 { + // SAFETY: geteuid takes no arguments, has no failure mode, and reads only + // the process credential maintained by the kernel. + unsafe { libc::geteuid() } +} + +// INTENTIONAL: Windows ACL read-check (needs GetSecurityInfo) deferred to +// https://github.com/dashpay/platform/issues/3754 — set ACLs manually. #[cfg(not(unix))] -fn check_perms(_meta: &fs::Metadata) -> Result<(), SecretStoreError> { +fn check_perms(_path: &Path, _meta: &fs::Metadata) -> Result<(), SecretStoreError> { Ok(()) } @@ -1018,12 +1389,8 @@ fn set_restrictive_perms(f: &fs::File) -> Result<(), SecretStoreError> { Ok(()) } -// INTENTIONAL: Windows ACL tightening deferred to the same follow-up -// as `check_perms` above — tracked at -// https://github.com/dashpay/platform/issues/3754. Vault file mode -// hardening on Windows requires SetSecurityInfo via `windows-acl` or -// `winapi`; out of scope for the secrets-feature landing. Operators on -// Windows MUST set ACLs manually until the follow-up lands. +// INTENTIONAL: Windows ACL tightening (needs SetSecurityInfo) deferred to +// https://github.com/dashpay/platform/issues/3754 — set ACLs manually. #[cfg(not(unix))] fn set_restrictive_perms(_f: &fs::File) -> Result<(), SecretStoreError> { Ok(()) @@ -1055,6 +1422,13 @@ mod tests { } fn vault_path(dir: &Path) -> PathBuf { + // Tighten the umask-0002 tempdir (0o775) to 0o700 so it passes the + // parent-dir perm check (dedicated perm tests use a subdir). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); + } dir.join("vault.pwsvault") } @@ -1086,9 +1460,7 @@ mod tests { #[test] fn open_creates_vault_file_on_first_open() { - // Resident-vault model: open() creates a usable vault file even - // without any subsequent put, so a second open() of the same - // path observes a real on-disk file (modulo the lock). + // open() creates a usable vault file even without a put. let dir = tempfile::tempdir().unwrap(); let path = vault_path(dir.path()); { @@ -1114,6 +1486,26 @@ mod tests { assert!(matches!(missing, KeyringError::NoEntry)); } + /// The durability-uncertain counter exists, starts at 0, and a normal + /// write (whose parent-dir fsync is confirmed) does NOT bump it. The + /// increment-on-fsync-FAILURE path is environment-specific and not forced + /// here; this guards the accessor + the no-spurious-bump invariant. + #[test] + fn durability_uncertain_count_zero_for_confirmed_writes() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = store_at(&path); + assert_eq!(s.durability_uncertain_count(), 0, "fresh store has none"); + entry(&s, wid(1), "bip39_mnemonic") + .set_secret(b"abandon abandon") + .unwrap(); + assert_eq!( + s.durability_uncertain_count(), + 0, + "a confirmed-durable write must not bump the counter" + ); + } + #[test] fn wrong_passphrase_on_reopen_fails_with_typed_error() { let dir = tempfile::tempdir().unwrap(); @@ -1135,10 +1527,8 @@ mod tests { #[test] fn open_acquires_exclusive_lock_until_drop() { - // Resident-vault model: a second open() of the same path while - // the first store is alive returns AlreadyLocked immediately - // (no retry, no wait). Once the first store drops the lock is - // released and a fresh open() succeeds. + // A second open() while the first store is alive returns + // AlreadyLocked; once it drops, a fresh open() succeeds. let dir = tempfile::tempdir().unwrap(); let path = vault_path(dir.path()); let s1 = store_at(&path); @@ -1258,7 +1648,38 @@ mod tests { let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) .expect_err("loose perms must be refused at open"); assert!( - matches!(err, SecretStoreError::InsecurePermissions { mode: 0o644 }), + matches!( + err, + SecretStoreError::InsecurePermissions { mode: 0o644, .. } + ), + "got {err:?}" + ); + } + + #[cfg(unix)] + #[test] + fn foreign_owned_preexisting_file_refused() { + use std::os::unix::fs::MetadataExt; + + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let _s = store_at(&path); + } + let meta = fs::metadata(&path).unwrap(); + let owner = meta.uid(); + let other_uid = owner.wrapping_add(1); + let err = check_perms_for_uid(&path, &meta, other_uid) + .expect_err("a vault owned by another user must be refused"); + assert!( + matches!( + err, + SecretStoreError::InsecureOwnership { + path: ref error_path, + found, + expected, + } if error_path == &path && found == owner && expected == other_uid + ), "got {err:?}" ); } @@ -1271,7 +1692,7 @@ mod tests { let s = store_at(&path); entry(&s, wid(1), "seed").set_secret(b"value").unwrap(); let pre = fs::read(&path).unwrap(); - s.rekey(SecretString::new("pw-new")).unwrap(); + s.rekey(SecretString::new("password-new")).unwrap(); assert_eq!(entry(&s, wid(1), "seed").get_secret().unwrap(), b"value"); let new_bytes = fs::read(&path).unwrap(); assert_ne!(pre, new_bytes); @@ -1357,7 +1778,7 @@ mod tests { let original_bytes = fs::read(&path).unwrap(); fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap(); - let result = s.rekey(SecretString::new("pw-new")); + let result = s.rekey(SecretString::new("password-new")); fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); assert!(result.is_err(), "rekey should have failed: {result:?}"); @@ -1376,6 +1797,62 @@ mod tests { ); } + /// A parent-dir fsync failure that occurs AFTER `persist()` has already + /// committed the vault to disk must NOT cause `put` to return an error or + /// roll back the in-memory entry. The vault is already on disk; propagating + /// the post-persist error would diverge in-memory state from the on-disk file. + /// + /// Trigger: set the parent dir to `0o300` (write + execute, no read) so + /// `persist()` (a rename — needs write) succeeds but the subsequent + /// `fs::File::open(parent)` for fsync fails (needs read). + #[cfg(unix)] + #[test] + fn put_succeeds_when_parent_dir_fsync_fails_post_persist() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = store_at(&path); + entry(&s, wid(1), "seed") + .set_secret(b"first-value") + .unwrap(); + + // Tighten the parent dir to write+execute only (no read): + // - NamedTempFile::new_in : needs write (0o200) + execute (0o100) → OK + // - persist() (rename) : needs write on directory → OK + // - fs::File::open(parent) : needs read (0o400) — MISSING → FAILS + // This forces the post-persist parent-dir fsync to fail, which is the + // scenario under test. + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o300)).unwrap(); + let result = entry(&s, wid(1), "seed").set_secret(b"second-value"); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + + // Post-fix: put must succeed even though the parent-dir fsync failed — + // the vault was already committed via persist(). + assert!( + result.is_ok(), + "put must succeed even when parent-dir fsync fails post-persist; got {result:?}" + ); + + // In-memory state was NOT rolled back. + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"second-value", + "in-memory state must reflect the committed put" + ); + + // Drop `s` (releases the vault lock) before opening a second store on + // the same file — `EncryptedFileStore::open` is exclusive by design. + drop(s); + + // The vault on disk reflects the committed value. + let reopened = EncryptedFileStore::open(&path, SecretString::new("pw-correct")).unwrap(); + assert_eq!( + entry(&reopened, wid(1), "seed").get_secret().unwrap(), + b"second-value", + "vault on disk must have the committed value after post-persist fsync failure" + ); + } + #[test] fn get_corruption_after_verify_token_is_not_wrong_passphrase() { let dir = tempfile::tempdir().unwrap(); @@ -1422,7 +1899,7 @@ mod tests { // Rekey re-encrypts every entry under the new key — the // corrupt entry fails AEAD-open under the (correct) old key, // and we project that as Corruption. - let err = s.rekey(SecretString::new("pw-new")).unwrap_err(); + let err = s.rekey(SecretString::new("password-new")).unwrap_err(); assert!( matches!(err, SecretStoreError::Corruption), "unexpected error: {err:?}" @@ -1455,6 +1932,233 @@ mod tests { ); } + /// The no-plaintext-at-rest guarantee also holds through the public + /// `SecretStore::set` path (which writes an unprotected envelope sealed + /// under the vault key), not just the raw SPI entry path. + #[test] + fn no_plaintext_in_vault_file_via_secret_store_set() { + use crate::secrets::SecretStore; + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let store = SecretStore::file(&path, SecretString::new("pw-correct")).unwrap(); + store + .set( + &wid(1), + "seed", + &SecretBytes::from_slice(b"PLAINTEXTNEEDLE"), + ) + .unwrap(); + let raw = fs::read(&path).unwrap(); + assert!( + raw.windows(b"PLAINTEXTNEEDLE".len()) + .all(|w| w != b"PLAINTEXTNEEDLE"), + "plaintext leaked into vault file via SecretStore::set" + ); + } + + /// A blank passphrase is rejected at `open` → + /// `BlankPassphrase`; no vault file (or lock sidecar) is created. + #[test] + fn open_rejects_blank_passphrase() { + for blank in [ + SecretString::empty(), + SecretString::new(""), + SecretString::new(" "), + SecretString::new("\t\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let err = EncryptedFileStore::open(&path, blank).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "blank passphrase must be rejected, got {err:?}" + ); + assert!(!path.exists(), "no vault file for a blank passphrase"); + assert!( + !lock_path_for(&path).exists(), + "no lock sidecar for a blank passphrase" + ); + } + } + + #[test] + fn open_accepts_minimum_and_rejects_shorter_passphrases() { + let short_dir = tempfile::tempdir().unwrap(); + let short_path = vault_path(short_dir.path()); + let err = EncryptedFileStore::open(&short_path, SecretString::new("1234567")) + .expect_err("seven-byte passphrase must be rejected"); + assert!(matches!(err, SecretStoreError::BlankPassphrase)); + assert!(!short_path.exists(), "rejection must not create a vault"); + + let minimum_dir = tempfile::tempdir().unwrap(); + let minimum_path = vault_path(minimum_dir.path()); + EncryptedFileStore::open(&minimum_path, SecretString::new("12345678")) + .expect("eight-byte passphrase must be accepted"); + } + + /// The passphrase ceiling holds at `open` and at `rekey`: it is what + /// keeps the resident-passphrase row of `MAX_SECRET_LEN`'s + /// locked-memory budget a bounded one, so it cannot be assumed. + /// Rejection leaves no vault behind and no vault changed. + #[test] + fn open_and_rekey_accept_the_passphrase_cap_and_reject_past_it() { + let at_cap = || SecretString::new("p".repeat(MAX_PASSPHRASE_LEN)); + let over = || SecretString::new("p".repeat(MAX_PASSPHRASE_LEN + 1)); + + let over_dir = tempfile::tempdir().unwrap(); + let over_path = vault_path(over_dir.path()); + let err = EncryptedFileStore::open(&over_path, over()) + .expect_err("a passphrase past the cap must be rejected"); + assert!( + matches!( + err, + SecretStoreError::PassphraseTooLong { found, max } + if found == MAX_PASSPHRASE_LEN + 1 && max == MAX_PASSPHRASE_LEN + ), + "got {err:?}" + ); + assert!(!over_path.exists(), "rejection must not create a vault"); + assert!(!lock_path_for(&over_path).exists(), "nor a lock sidecar"); + + // The cap itself is accepted, and rekey applies the same bounds. + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = EncryptedFileStore::open(&path, at_cap()) + .expect("a passphrase exactly at the cap must be accepted"); + entry(&s, wid(1), "seed").set_secret(b"v1").unwrap(); + + let err = s.rekey(over()).expect_err("rekey must apply the cap too"); + assert!( + matches!(err, SecretStoreError::PassphraseTooLong { .. }), + "got {err:?}" + ); + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"v1", + "a rejected rekey must leave the vault readable under the old passphrase" + ); + } + + /// A blank passphrase is rejected at `rekey`; the resident + /// vault, key, and on-disk file are UNCHANGED — the original passphrase + /// still reads every entry, live and after reopen. + #[test] + fn rekey_rejects_blank_passphrase_vault_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = store_at(&path); // real "pw-correct" + entry(&s, wid(1), "seed").set_secret(b"v1").unwrap(); + for blank in [SecretString::empty(), SecretString::new(" ")] { + let err = s.rekey(blank).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "blank rekey must be rejected, got {err:?}" + ); + } + // Old passphrase still reads the entry, live… + assert_eq!(entry(&s, wid(1), "seed").get_secret().unwrap(), b"v1"); + // …and after a clean reopen under the original passphrase. + drop(s); + let s2 = store_at(&path); + assert_eq!(entry(&s2, wid(1), "seed").get_secret().unwrap(), b"v1"); + } + + /// `open_unprotected` permits a deliberate keyless vault that + /// round-trips; a real-passphrase `open` of that keyless vault then + /// fails with `WrongPassphrase` (it is keyless, not real-pass). + #[test] + fn open_unprotected_permits_keyless_vault() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + entry(&s, wid(1), "seed") + .set_secret(b"keyless-seed") + .unwrap(); + } + { + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"keyless-seed" + ); + } + let err = + EncryptedFileStore::open(&path, SecretString::new("real-passphrase")).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "real-pass open of a keyless vault must fail, got {err:?}" + ); + } + + /// Empty→real passphrase migration via `rekey`. After rekey, + /// `open(real)` reads every entry; the keyless door no longer opens it; + /// no `.bak`/`.tmp` residue beside the vault. + #[test] + fn empty_to_real_rekey_migration() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + entry(&s, wid(1), "seed").set_secret(b"migrate-me").unwrap(); + s.rekey(SecretString::new("real-pass")).unwrap(); + // The live handle keeps working post-rekey. + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"migrate-me" + ); + } + // Reopen under the real passphrase reads the entry. + { + let s = EncryptedFileStore::open(&path, SecretString::new("real-pass")).unwrap(); + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"migrate-me" + ); + } + // The keyless door no longer opens it. + let err = EncryptedFileStore::open_unprotected(&path).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "keyless open after migration must fail, got {err:?}" + ); + // No .bak / .tmp residue (mirrors rekey_reencrypts_and_old_passphrase_fails). + for sibling in fs::read_dir(dir.path()).unwrap().flatten() { + let name = sibling.file_name(); + let name = name.to_string_lossy(); + assert!( + !name.ends_with(".bak") && !name.ends_with(".tmp"), + "unexpected residue: {name}" + ); + } + } + + /// Crash-safety: a disk-write failure mid-rekey leaves the + /// pre-rekey keyless vault intact and readable via `open_unprotected` + /// (mirrors rekey_does_not_corrupt_on_disk_temp_failure). + #[cfg(unix)] + #[test] + fn empty_to_real_rekey_crash_safe_stays_keyless() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + entry(&s, wid(1), "seed").set_secret(b"keyless").unwrap(); + + // Read-only parent → the rekey atomic temp-write fails. + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap(); + let err = s.rekey(SecretString::new("real-pass")).unwrap_err(); + assert!(matches!(err, SecretStoreError::Io(_)), "got {err:?}"); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + + // The live handle still serves the pre-rekey keyless vault… + assert_eq!(entry(&s, wid(1), "seed").get_secret().unwrap(), b"keyless"); + // …and on disk it is still the keyless vault. + drop(s); + let s2 = EncryptedFileStore::open_unprotected(&path).unwrap(); + assert_eq!(entry(&s2, wid(1), "seed").get_secret().unwrap(), b"keyless"); + } + #[test] fn build_rejects_malformed_service() { let dir = tempfile::tempdir().unwrap(); @@ -1552,13 +2256,9 @@ mod tests { #[test] fn inflated_kdf_params_fail_open_with_kdf_failure() { - // A vault whose JSON declares m_kib = u32::MAX must be refused - // at open() with KdfFailure — before the verify-token is - // derived and without the ~4 TiB allocation the inflated param - // would demand. Under the resident-vault model this surfaces at - // open() rather than on first get(). Drop the store BEFORE - // patching the on-disk file so the drop-time sync cannot - // overwrite our injected corruption. + // A JSON m_kib = u32::MAX must be refused at open() with + // KdfFailure, before the ~4 TiB allocation it would demand. Drop + // the store before patching disk so the drop-sync can't undo it. let dir = tempfile::tempdir().unwrap(); let path = vault_path(dir.path()); { @@ -1567,12 +2267,107 @@ mod tests { } let mut vault = read_vault_at(&path).unwrap().unwrap(); vault.kdf.m_kib = u32::MAX; - write_vault_at(&path, &vault).unwrap(); + write_vault_at(&path, &vault, None).unwrap(); let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) .expect_err("inflated KDF must fail open"); assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); } + /// A header above the absolute DoS ceiling is refused on the read path, + /// before `m_kib` reaches the allocator. + #[test] + fn vault_header_read_ceiling_rejects_inflated_m_kib() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let _s = store_at(&path); + } + let mut vault = read_vault_at(&path).unwrap().unwrap(); + vault.kdf.m_kib = crypto::ARGON2_MAX_M_KIB + 1; + write_vault_at(&path, &vault, None).unwrap(); + + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("a vault header above the DoS ceiling must be refused"); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// The read path must NOT clamp to `default_target()`. `default_target` is + /// a write-side tunable; a read gate keyed to it orphans every vault + /// written under a different value the day it moves. This pins the + /// tolerance in the direction that is cheap to test — a header above the + /// current default still opens — which is the same property that keeps + /// vaults readable after the default is LOWERED. + #[test] + fn vault_read_path_does_not_clamp_to_the_current_default_target() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let target = KdfParams::default_target(); + // Only just off the target: enough to prove the read path does not + // clamp, without paying a second full-size Argon2 derivation. + let off_target = KdfParams { + m_kib: target.m_kib + 1024, + t: target.t + 1, + ..target + }; + assert!( + off_target.enforce_bounds().is_ok(), + "fixture must be in-band" + ); + assert!(off_target.m_kib > target.m_kib && off_target.t > target.t); + + let pass = SecretString::new("pw-correct"); + let (vault, _key) = build_fresh_vault(&pass, off_target).expect("build off-target vault"); + write_vault_at(&path, &vault, None).expect("write off-target vault"); + EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect("a vault whose header differs from the shipped target must still open"); + } + + /// A rotation rewrites the header to the rotating handle's own target, and + /// the vault stays openable under the new passphrase. A floor (mock) + /// handle over an off-target vault is the sharpest case: the two values + /// differ on both axes, so a header that came back unchanged would mean the + /// rotation had not rewritten it at all. + #[test] + fn rekey_rewrites_the_header_to_the_handle_target() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let floor = KdfParams::floor_target(); + // Just above the floor on both axes: distinguishable from the handle's + // own target, cheap enough to derive under three times. + let off_target = KdfParams { + m_kib: floor.m_kib + 1024, + t: floor.t + 1, + ..floor + }; + assert!( + off_target.enforce_bounds().is_ok(), + "fixture must be in-band" + ); + + let (vault, _key) = build_fresh_vault(&SecretString::new("pw-correct"), off_target) + .expect("build off-target vault"); + write_vault_at(&path, &vault, None).expect("write off-target vault"); + + { + let store = EncryptedFileStore::open_mock(&path, SecretString::new("pw-correct")) + .expect("open off-target vault with a floor handle"); + assert_eq!( + store.kdf_params(), + floor, + "mock handle derives at the floor" + ); + store.rekey(SecretString::new("pw-rotated")).expect("rekey"); + } + + let after = read_vault_at(&path).unwrap().unwrap(); + assert_eq!( + after.kdf, floor, + "rekey must rewrite the header to the rotating handle's target" + ); + EncryptedFileStore::open_mock(&path, SecretString::new("pw-rotated")) + .expect("the rotated passphrase must open the rewritten vault"); + } + #[test] fn persistence_is_until_delete() { let dir = tempfile::tempdir().unwrap(); @@ -1613,14 +2408,10 @@ mod tests { ); } - /// Two threads share a cloned [`EncryptedFileStore`] handle (same - /// shape [`EncryptedFileCredential`] uses internally): one hammers - /// `put_bytes` + `get_bytes`, the other calls `rekey`. The single - /// state lock serializes every put/get/rekey, so a put can never - /// capture an old key and insert under a newly-swapped vault — every - /// `get` returns either the right plaintext, `Ok(None)`, or a clean - /// typed error, NEVER garbled bytes from a mis-keyed seal (which - /// would surface as `Corruption`). + /// Two threads share a cloned handle: one hammers put/get, the other + /// rekeys. The single state lock serializes them, so a `get` only ever + /// returns the right plaintext, `Ok(None)`, or a clean typed error — + /// never garbled bytes from a put that sealed under a swapped key. #[test] fn rekey_does_not_race_put_into_corruption() { let dir = tempfile::tempdir().unwrap(); @@ -1629,19 +2420,15 @@ mod tests { let writer_store = store.clone(); let rekeyer_store = store.clone(); - // Iteration counts are tuned for cost: every rekey runs Argon2 - // at the default-target params, so the rekey loop dominates - // wall-clock. 16 rekeys overlap a 200-iter put loop reliably - // enough to hit the pre-fix race window on the test runner - // without dragging the suite out. + // Counts tuned for cost: each rekey runs Argon2, so 16 rekeys + // overlapping a 200-iter put loop hits the race window affordably. const PUT_ITERS: usize = 200; const REKEY_ITERS: usize = 16; let wallet = wid(7); let label = "racy"; - // A fixed-prefix payload byte vector — never built with - // `format!` so the in-source secrets-guard scanner does not - // flag this test as a sink/expose_secret pairing. + // Fixed prefix, never built with `format!` so the secrets-guard + // scanner does not flag this as a sink/expose_secret pairing. const PREFIX: &[u8] = b"payload-"; let writer = std::thread::spawn(move || { let mut buf = Vec::with_capacity(PREFIX.len() + 4); @@ -1654,9 +2441,8 @@ mod tests { .expect("put"); match writer_store.get_bytes(&wallet, label) { Ok(Some(bytes)) => { - // Must be one of OUR payloads — never random - // bytes from a mis-keyed seal. Compare only - // length + prefix; never log the bytes. + // Must be one of OUR payloads, never mis-keyed + // garbage. Check length + prefix; never log bytes. let got = bytes.expose_secret(); assert!(got.starts_with(PREFIX), "garbled get-after-put"); assert_eq!(got.len(), PREFIX.len() + 4); @@ -1668,11 +2454,9 @@ mod tests { }); let rekeyer = std::thread::spawn(move || { - // Alternate two passphrases so consecutive rekeys actually - // change the resident key (the salt rerolls regardless, but - // alternating distinct passphrases is the operator-facing - // model and keeps the race window real). - let passphrases = ["pw-A", "pw-B"]; + // Alternate passphrases so consecutive rekeys change the + // resident key, keeping the race window real. + let passphrases = ["password-A", "password-B"]; for i in 0..REKEY_ITERS { rekeyer_store .rekey(SecretString::new(passphrases[i % 2])) @@ -1684,20 +2468,23 @@ mod tests { rekeyer.join().expect("rekeyer thread"); } - /// A bare relative filename makes `Path::parent()` return `Some("")`, - /// which `NamedTempFile::new_in("")` and the Unix parent-dir fsync - /// both reject; the `normalized_parent` helper rewrites the empty - /// parent to ".". Switch cwd to a temp dir for the test scope so we - /// exercise the bare-filename path without scribbling in the - /// workspace. + /// A bare filename makes `Path::parent()` return `Some("")`, which + /// `normalized_parent` rewrites to "."; exercise that path in a temp + /// cwd so nothing lands in the workspace. #[test] fn open_and_put_with_bare_filename_uses_cwd() { - // A static mutex serializes cwd-changing tests so they cannot - // race each other across the suite. + // Serialize cwd-changing tests so they cannot race each other. static CWD_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); let _g = CWD_GUARD.lock().unwrap_or_else(|p| p.into_inner()); let dir = tempfile::tempdir().unwrap(); + // Tighten the cwd-parent so the parent-dir perm check passes (a + // umask-0002 tempdir is group-writable at 0o775). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + } let prior = std::env::current_dir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); // Tear-down guard so a panic still restores cwd. @@ -1720,9 +2507,8 @@ mod tests { assert!(dir.path().join("vault.pwsvault").exists()); } - /// The lock sidecar must refuse to traverse a pre-existing symlink - /// at the lock path on Unix. Without `O_NOFOLLOW` an attacker could - /// redirect the lock file's open to an unrelated inode. + /// The lock-sidecar open must refuse a pre-existing symlink + /// (`O_NOFOLLOW`) so an attacker can't redirect it to another inode. #[cfg(unix)] #[test] fn vault_lock_rejects_symlink_at_lock_path() { @@ -1731,9 +2517,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = vault_path(dir.path()); let lock = lock_path_for(&path); - // Point the lock path at /dev/null. Any successful open of the - // symlink would land on /dev/null's inode; O_NOFOLLOW makes the - // open itself fail with ELOOP. + // O_NOFOLLOW makes the open of this symlink fail with ELOOP. symlink("/dev/null", &lock).unwrap(); let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) @@ -1743,4 +2527,162 @@ mod tests { "expected an Io error from O_NOFOLLOW refusal, got {err:?}" ); } + + /// A group/other-WRITABLE parent is refused at open (it would let a + /// peer rename/replace the vault despite its 0600); a read-only 0o750 + /// parent is fine. + #[cfg(unix)] + #[test] + fn writable_parent_dir_is_refused() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let sub = dir.path().join("vaultdir"); + fs::create_dir(&sub).unwrap(); + // Group-writable (0o770) trips the write-bit check. Build the path + // directly — `vault_path` would tighten the dir back to 0o700. + fs::set_permissions(&sub, fs::Permissions::from_mode(0o770)).unwrap(); + let path = sub.join("vault.pwsvault"); + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("writable parent dir must be refused"); + assert!( + matches!( + err, + SecretStoreError::InsecureParentDir { + reason: crate::parent_permissions::InsecureAncestor::WritableWithoutSticky { + mode + }, + .. + } if mode & 0o022 != 0 + ), + "got {err:?}" + ); + // Dropping the write bits (still group-readable at 0o750) lets the + // open succeed: read-only group access is not a rename threat. + fs::set_permissions(&sub, fs::Permissions::from_mode(0o750)).unwrap(); + let _s = store_at(&path); + } + + /// An oversized secret is rejected at the write boundary with + /// `SecretTooLarge`, and the vault stays openable — the per-secret + /// cap prevents the shared document from being inflated past the + /// read-side ceiling. + #[test] + fn oversized_secret_rejected_and_vault_stays_openable() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = store_at(&path); + entry(&s, wid(1), "ok").set_secret(b"small").unwrap(); + let too_big = vec![0xABu8; MAX_SECRET_LEN + 1]; + let err = entry(&s, wid(1), "huge").set_secret(&too_big).unwrap_err(); + // Surfaces through the SPI as BadStoreFormat carrying the + // secret-free SecretTooLarge message. + assert!( + matches!(&err, KeyringError::BadStoreFormat(m) + if *m == SecretStoreError::SecretTooLarge { + found: MAX_SECRET_LEN + 1, + max: MAX_SECRET_LEN, + }.to_string()), + "got {err:?}" + ); + // The earlier good entry is still readable on this handle. + assert_eq!(entry(&s, wid(1), "ok").get_secret().unwrap(), b"small"); + } + // The vault reopens cleanly — the oversized put never landed. + let s2 = store_at(&path); + assert_eq!(entry(&s2, wid(1), "ok").get_secret().unwrap(), b"small"); + assert!(matches!( + entry(&s2, wid(1), "huge").get_secret(), + Err(KeyringError::NoEntry) + )); + } + + /// An in-bounds KDF-param shift on a correct-passphrase vault is + /// rejected at open with `WrongPassphrase` — driven by the changed + /// DERIVED KEY, not the AAD binding (which `verify_aad_binds_salt_and_ + /// kdf_params` covers). + #[test] + fn header_tamper_kdf_shift_smoke_test() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = store_at(&path); + entry(&s, wid(1), "seed").set_secret(b"value").unwrap(); + } + let mut vault = read_vault_at(&path).unwrap().unwrap(); + // Shift to still-valid-but-weaker params (defaults are 64 MiB/t=3; + // halving m_kib and dropping t stays above the 19 MiB / t=2 floor). + vault.kdf.m_kib /= 2; + vault.kdf.t -= 1; + assert!( + vault.kdf.enforce_bounds().is_ok(), + "shift must stay in bounds" + ); + write_vault_at(&path, &vault, None).unwrap(); + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("KDF-param shift must fail the verify-token"); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "got {err:?}" + ); + } + + /// A flipped salt byte on a correct-passphrase vault is rejected at + /// open with `WrongPassphrase` — driven by the changed DERIVED KEY + /// (salt feeds the KDF), not the AAD binding. + #[test] + fn header_tamper_flipped_salt_smoke_test() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = store_at(&path); + entry(&s, wid(1), "seed").set_secret(b"value").unwrap(); + } + let mut vault = read_vault_at(&path).unwrap().unwrap(); + vault.salt[0] ^= 0x01; + write_vault_at(&path, &vault, None).unwrap(); + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("flipped salt must fail open"); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "got {err:?}" + ); + } + + /// A flipped entry NONCE byte (verify-token intact) surfaces as + /// `Corruption`: the per-entry AEAD-open fails its tag under the + /// correct key, mirroring the ciphertext-flip route. + #[test] + fn flipped_entry_nonce_is_corruption() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = store_at(&path); + entry(&s, wid(1), "seed").set_secret(b"value").unwrap(); + let mut vault = s.test_read_vault_from_disk().unwrap().unwrap(); + vault + .wallets + .get_mut(&wid(1).to_hex()) + .unwrap() + .get_mut("seed") + .unwrap() + .nonce[0] ^= 0x01; + s.test_write_vault_to_disk(&vault).unwrap(); + s.test_reload_from_disk().unwrap(); + let err = entry(&s, wid(1), "seed").get_secret().unwrap_err(); + assert!(is_corruption(&err), "unexpected error: {err:?}"); + } + + /// A secret exactly at the cap is accepted (boundary is inclusive). + #[test] + fn secret_exactly_at_cap_is_accepted() { + let dir = tempfile::tempdir().unwrap(); + let s = store_at(&vault_path(dir.path())); + let at_cap = vec![0x5Au8; MAX_SECRET_LEN]; + entry(&s, wid(1), "atcap").set_secret(&at_cap).unwrap(); + assert_eq!( + entry(&s, wid(1), "atcap").get_secret().unwrap().len(), + MAX_SECRET_LEN + ); + } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/guarded.rs b/packages/rs-platform-wallet-storage/src/secrets/guarded.rs new file mode 100644 index 00000000000..8130963850e --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/guarded.rs @@ -0,0 +1,477 @@ +//! The crate's only `unsafe` code: a guarded heap buffer for secret bytes. +//! +//! [`GuardedBuf`] wraps [`memsec`]'s hardened allocator. Every allocation +//! is page-aligned, fenced by inaccessible guard pages, canary-checked, +//! `mlock`ed, and excluded from core dumps (`MADV_DONTDUMP`). Because the +//! data pages belong to one buffer outright, two live secrets can never +//! share a page — so freeing one can never unlock memory another still +//! holds, the failure mode that makes page-granular locking hazardous +//! over ordinary allocations. +//! +//! # Why the `unsafe_code` carve-out lives here +//! +//! `memsec`'s allocator is an `unsafe` FFI-shaped API, so it cannot be +//! called under the crate-level `deny(unsafe_code)`. The carve-out is +//! confined to this module and applied **per item**, never as a +//! module-wide `allow`: a new `unsafe` block added anywhere in this file +//! still trips the crate lint and has to justify itself. Callers see a +//! safe, slice-shaped API and never handle a raw pointer. +//! +//! # Verification tools this module forecloses +//! +//! `memsec` takes its pages from the Rust global allocator and then +//! `mprotect`s them in place, so the `secrets` tree cannot be run under +//! Miri (which cannot execute the `mprotect`/`mlock` FFI) nor under +//! ASan/LSan/libFuzzer (segfaults, memsec issue +//! [#14](https://github.com/quininer/memsec/issues/14)). It also assumes +//! the system allocator: a downstream binary installing a +//! `#[global_allocator]` may hand memsec pages whose allocator metadata +//! sits inside the block it protects. Accepted cost of the dependency; +//! the `unsafe` below is small enough to hold by inspection, and any +//! future sanitizer job must keep `secrets` off. + +use std::alloc::Layout; +use std::ptr::NonNull; + +use zeroize::Zeroize; + +use super::error::SecretStoreError; + +/// A page-aligned, guard-paged, `mlock`ed byte buffer. +/// +/// Owns its allocation exclusively for its whole lifetime and wipes it +/// on drop. `cap` is the usable payload length; `memsec` places it so the +/// payload ends flush against the trailing guard page. +pub(super) struct GuardedBuf { + ptr: NonNull, + cap: usize, +} + +// SAFETY: `GuardedBuf` uniquely owns its allocation and offers no interior +// mutability, so it is exactly as safe to send and share as `Box<[u8]>`. +// Holding the raw pointer behind this type keeps `SecretString` and +// `SecretBytes` free of manual unsafe trait impls. +#[allow(unsafe_code)] +unsafe impl Send for GuardedBuf {} +#[allow(unsafe_code)] +unsafe impl Sync for GuardedBuf {} + +impl GuardedBuf { + /// Allocate `cap` zeroed bytes of guarded memory. + /// + /// A failed page lock is warned about but not fatal: the buffer is + /// still guard-paged and wiped, it may merely be swappable. + /// + /// # Panics + /// + /// Panics if `cap` is `0` — memsec would place the payload pointer on + /// the first byte of the trailing `PROT_NONE` guard page, so handing + /// that address to anything that writes takes an immediate `SIGSEGV`. + /// Empty secrets hold no allocation at all; see `SecretString` and + /// `SecretBytes`. + /// + /// Panics (via [`std::alloc::handle_alloc_error`]) if guarded memory + /// is exhausted. The secret constructors are infallible by contract, + /// so this is handled the way the global allocator handles ordinary + /// exhaustion. + pub(super) fn new(cap: usize) -> Self { + Self::new_with_lock(cap, lock_payload) + } + + /// Share the allocation path with deterministic lock-failure tests. + fn new_with_lock(cap: usize, lock: impl FnOnce(NonNull, usize) -> bool) -> Self { + assert!(cap > 0, "a guarded buffer must hold at least one byte"); + // SAFETY: `malloc_sized` takes a plain byte count and returns a + // pointer to that many writable bytes, or `None` on failure. + #[allow(unsafe_code)] + let ptr = unsafe { memsec::malloc_sized(cap) }.unwrap_or_else(|| alloc_failed(cap)); + let mut buf = Self { + ptr: ptr.cast(), + cap, + }; + // `malloc_sized` locks the region but discards the result, so a + // failed lock would otherwise be indistinguishable from a + // successful one. Re-locking is a no-op when it already took. + if !lock(buf.ptr, cap) { + tracing::warn!( + "secret pages could not be locked into RAM and may reach swap; \ + raise RLIMIT_MEMLOCK for this process" + ); + } + // memsec hands back a garbage-filled block, so zero it here: every + // byte past a secret's length is then guaranteed zero from the + // first write onwards. + buf.zeroize_all(); + #[cfg(test)] + gauge::allocated(locked_cost(cap)); + buf + } + + /// The usable payload length — what a growing edit compares its + /// required size against, and what [`zeroize_all`](Self::zeroize_all) + /// wipes. Distinct from a secret's live length, which each wrapper + /// tracks itself. + pub(super) fn capacity(&self) -> usize { + self.cap + } + + /// The first `len` bytes. Callers guarantee they are initialised, + /// which holds for the whole buffer from [`GuardedBuf::new`] onwards. + /// + /// # Panics + /// + /// Panics if `len` exceeds [`capacity`](Self::capacity) — a caller + /// tracking a length its buffer cannot hold is a bug in this module, + /// and the bound is what keeps the slice construction sound, so it + /// must survive into release builds. + pub(super) fn as_slice(&self, len: usize) -> &[u8] { + assert!(len <= self.cap, "secret length exceeds guarded capacity"); + // SAFETY: the allocation is valid and uniquely owned for `cap` + // bytes, all of which `new` initialised, and `len <= cap`. + #[allow(unsafe_code)] + unsafe { + std::slice::from_raw_parts(self.ptr.as_ptr(), len) + } + } + + /// The first `len` bytes, mutably. + /// + /// # Panics + /// + /// As [`as_slice`](Self::as_slice). + pub(super) fn as_mut_slice(&mut self, len: usize) -> &mut [u8] { + assert!(len <= self.cap, "secret length exceeds guarded capacity"); + // SAFETY: as `as_slice`, and `&mut self` guarantees exclusivity. + #[allow(unsafe_code)] + unsafe { + std::slice::from_raw_parts_mut(self.ptr.as_ptr(), len) + } + } + + /// The buffer's start address, for the page-isolation tests to + /// compare against page boundaries. Never dereferenced by callers, + /// and absent outside tests so no production path can hold one. + #[cfg(test)] + pub(super) fn addr(&self) -> usize { + self.ptr.as_ptr() as usize + } + + /// Volatile-zero every byte, including capacity past any live length. + pub(super) fn zeroize_all(&mut self) { + let cap = self.cap; + self.as_mut_slice(cap).zeroize(); + } +} + +impl Drop for GuardedBuf { + fn drop(&mut self) { + // `memsec::free` wipes the region too, but the wipe is this + // module's guarantee rather than a dependency's implementation + // detail, so it is not left to `free` alone. + self.zeroize_all(); + #[cfg(test)] + gauge::freed(locked_cost(self.cap)); + // SAFETY: `ptr` came from `memsec::malloc_sized`, is owned solely + // by this value, and is freed exactly once — here. + #[allow(unsafe_code)] + unsafe { + memsec::free(self.ptr) + }; + } +} + +/// The memory page size every locked-memory figure in this crate is +/// computed against. +/// +/// An assumption about the host, not a fact: `memsec` rounds each +/// allocation up to the size the kernel reports at run time +/// (`sysconf(_SC_PAGESIZE)` / `GetSystemInfo`), which the compile-time +/// budget cannot see. [`verify_host_page_size`] is what turns the +/// assumption into a checked precondition. +/// +/// 16 KiB rather than the 4 KiB of x86-64 Linux, because the budget must +/// bound the *largest* supported host, not the commonest one: Apple +/// Silicon macOS and iOS use 16 KiB pages, and a 4 KiB assumption made +/// them unconstructible. Assuming 16 KiB costs a 4 KiB-page host nothing +/// at run time — `memsec` still rounds to that host's real 4 KiB — it +/// only makes [`locked_cost`] the over-estimate there that the budget is +/// already designed to tolerate. +pub(super) const ASSUMED_PAGE_SIZE: usize = 16384; + +/// Bytes `memsec` locks for a `payload`-byte secret: its 16-byte canary +/// prepended, then rounded up to whole [`ASSUMED_PAGE_SIZE`] pages. +/// +/// Mirrors `memsec`'s own `mlock(ptr, page_round(CANARY_SIZE + size))`, +/// so it is exact whenever the host's pages match the assumption and an +/// over-estimate when they are smaller. +/// +/// The unit the crate's locked-memory budget is denominated in — see the +/// table at [`MAX_SECRET_LEN`](crate::secrets::MAX_SECRET_LEN). +pub(super) const fn locked_cost(payload: usize) -> usize { + (16 + payload).div_ceil(ASSUMED_PAGE_SIZE) * ASSUMED_PAGE_SIZE +} + +/// Confirm this host's pages are no larger than [`ASSUMED_PAGE_SIZE`], +/// so [`locked_cost`] still bounds what `memsec` actually locks. +/// +/// Called at store construction: a store that cannot honour its own +/// locked-memory guarantee must refuse to exist rather than degrade +/// silently. Larger pages would overrun the `RLIMIT_MEMLOCK` budget by +/// the ratio between the two sizes, and `mlock` fails **open** — the +/// process keeps running with swappable seed and xpriv material and only +/// a warning to show for it. +/// +/// At a 16 KiB assumption the refusing branch is reserved for genuinely +/// exotic hosts — 64 KiB-page aarch64 RHEL/SLES builds. Every mainstream +/// target (4 KiB x86-64 and aarch64 Linux, 16 KiB Apple Silicon and iOS) +/// passes. +/// +/// # Errors +/// +/// [`SecretStoreError::HostPageSizeExceedsBudget`] when the host's pages +/// exceed the assumption. Smaller pages pass: they only make +/// [`locked_cost`] an over-estimate. +pub(super) fn verify_host_page_size() -> Result<(), SecretStoreError> { + check_page_size(region::page::size()) +} + +/// [`verify_host_page_size`] against a caller-supplied size, so the +/// refusing branch is reachable from a test on any host. +fn check_page_size(found: usize) -> Result<(), SecretStoreError> { + if found > ASSUMED_PAGE_SIZE { + return Err(SecretStoreError::HostPageSizeExceedsBudget { + found, + assumed: ASSUMED_PAGE_SIZE, + }); + } + Ok(()) +} + +/// Per-thread high-water mark of locked bytes, so a test can assert what +/// a flow actually costs instead of what its doc comment claims. +/// +/// Thread-local, not global: every allocation in a store operation +/// happens on the calling thread, so this measures the flow under test +/// without the other tests in this binary perturbing it. +#[cfg(test)] +pub(super) mod gauge { + use std::cell::Cell; + + thread_local! { + static LIVE: Cell = const { Cell::new(0) }; + static PEAK: Cell = const { Cell::new(0) }; + } + + pub(super) fn allocated(locked: usize) { + let live = LIVE.with(|l| { + let next = l.get() + locked; + l.set(next); + next + }); + PEAK.with(|p| p.set(p.get().max(live))); + } + + pub(super) fn freed(locked: usize) { + LIVE.with(|l| l.set(l.get().saturating_sub(locked))); + } + + /// Drop the high-water mark to what is live right now, so the next + /// [`peak`] reports only what the code under test adds on top. + pub(crate) fn reset_peak() { + LIVE.with(|l| PEAK.with(|p| p.set(l.get()))); + } + + /// The greatest number of locked bytes live on this thread since + /// [`reset_peak`], resident buffers included. + pub(crate) fn peak() -> usize { + PEAK.with(Cell::get) + } +} + +/// Lock the `cap` payload bytes at `ptr` into RAM, reporting whether the +/// kernel accepted it. +/// +/// The payload sits at the tail of the region memsec already locked, so +/// this re-lock covers a subset of it and is idempotent. Callers get a +/// `bool` rather than a log line because the failure is worth reporting +/// exactly once, at the allocation that suffered it. +fn lock_payload(ptr: NonNull, cap: usize) -> bool { + // SAFETY: `mlock` only passes the address to the kernel, which + // validates it; nothing here dereferences `ptr`. + #[allow(unsafe_code)] + unsafe { + memsec::mlock(ptr.as_ptr(), cap) + } +} + +/// Report unrecoverable exhaustion of guarded memory. +fn alloc_failed(cap: usize) -> ! { + match Layout::from_size_align(cap, 1) { + Ok(layout) => std::alloc::handle_alloc_error(layout), + Err(_) => panic!("secret capacity {cap} exceeds the maximum allocation size"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A host whose pages exceed the assumption is refused, and the error + /// names both sizes so the operator can see the mismatch. + /// + /// 32 and 64 KiB stand in for aarch64 RHEL/SLES kernels built with a + /// larger base page; every mainstream target now sits on the + /// accepting side. + #[test] + fn a_larger_host_page_is_refused_naming_both_sizes() { + for found in [32 * 1024, 64 * 1024] { + let err = check_page_size(found).expect_err("a page above the budget must be refused"); + assert!( + matches!( + err, + SecretStoreError::HostPageSizeExceedsBudget { found: f, assumed } + if f == found && assumed == ASSUMED_PAGE_SIZE + ), + "got {err:?}" + ); + let rendered = err.to_string(); + assert!( + rendered.contains(&found.to_string()) + && rendered.contains(&ASSUMED_PAGE_SIZE.to_string()), + "the message must name the host's page size and the assumed one: {rendered}" + ); + } + } + + /// Pages at or under the assumption pass: they leave `locked_cost` an + /// over-estimate, so the budget stays conservative. + /// + /// 4096 is listed by value, not left implicit: x86-64 and aarch64 + /// Linux are the crate's commonest hosts and must stay covered by + /// name however [`ASSUMED_PAGE_SIZE`] moves. + #[test] + fn a_page_at_or_under_the_assumption_is_accepted() { + for found in [1024, 2048, 4096, 8192, ASSUMED_PAGE_SIZE] { + check_page_size(found).expect("a page within the budget must be accepted"); + } + } + + /// A 16 KiB-page host constructs a store. Apple Silicon macOS and iOS + /// are 16 KiB-page targets with a Swift SDK and an FFI layer in this + /// repo, and the wallet CI runs on a `macOS, ARM64` runner, so a + /// refusal here is not a conservative default — it is the whole + /// `secrets` tree unavailable on a first-class platform. + #[test] + fn a_sixteen_kibibyte_page_host_is_accepted() { + check_page_size(16 * 1024).expect("Apple Silicon and iOS pages must be within the budget"); + } + + /// This host honours the assumption — otherwise every store + /// constructor in the suite would be exercising the refusing branch. + #[test] + fn this_host_honours_the_page_size_assumption() { + verify_host_page_size().expect("the secrets suite needs a host within the page budget"); + } + + /// Every row of the budget table costs exactly one guarded page. + /// + /// What collapses the budget from a sum of sizes into a count of live + /// secrets: each ceiling plus memsec's 16-byte canary fits inside one + /// [`ASSUMED_PAGE_SIZE`] page. A future edit raising any ceiling past + /// that spills a second page into every row that uses it, which this + /// catches and the peak assertion would then report only in total. + #[test] + fn every_budgeted_row_costs_one_page() { + use crate::secrets::{MAX_PASSPHRASE_LEN, MAX_PLAINTEXT_LEN, MAX_SECRET_LEN}; + + for payload in [32, MAX_PASSPHRASE_LEN, MAX_PLAINTEXT_LEN, MAX_SECRET_LEN] { + assert_eq!( + locked_cost(payload), + ASSUMED_PAGE_SIZE, + "a {payload}-byte budgeted row must fit one guarded page" + ); + } + } + + #[test] + fn new_is_zeroed_and_sized() { + let buf = GuardedBuf::new(64); + assert_eq!(buf.capacity(), 64); + assert!( + buf.as_slice(64).iter().all(|&b| b == 0), + "memsec fills with garbage; `new` must zero it" + ); + } + + /// The payload ends flush against the trailing guard page, which is + /// what makes page sharing between two secrets impossible. + #[test] + fn allocation_ends_on_a_page_boundary() { + let page = region::page::size(); + for cap in [1usize, 64, 4096 - 16, 8192] { + let buf = GuardedBuf::new(cap); + assert_eq!( + (buf.addr() + cap) % page, + 0, + "a {cap}-byte buffer must abut the guard page" + ); + } + } + + #[test] + fn writes_are_readable_and_wipe_clears_them() { + let mut buf = GuardedBuf::new(32); + buf.as_mut_slice(32).copy_from_slice(&[0xA5u8; 32]); + assert_eq!(buf.as_slice(32), &[0xA5u8; 32]); + buf.zeroize_all(); + assert!(buf.as_slice(32).iter().all(|&b| b == 0)); + } + + /// The allocation-failure path really is reachable and really does + /// panic rather than returning a bogus buffer. + /// + /// `usize::MAX` trips memsec's own `size >= usize::MAX - PAGE_SIZE * + /// 4` guard, so `malloc_sized` returns `None` without attempting a + /// mapping — a deterministic drive of `alloc_failed`, no memory + /// pressure required. Its other arm, [`std::alloc::handle_alloc_error`], + /// aborts the process and so cannot be exercised in-process. + #[test] + #[should_panic(expected = "exceeds the maximum allocation size")] + fn allocation_failure_panics() { + let _ = GuardedBuf::new(usize::MAX); + } + + /// A zero-length request is refused instead of handing back a pointer + /// to the first byte of the trailing `PROT_NONE` guard page. + #[test] + #[should_panic(expected = "at least one byte")] + fn zero_length_allocation_is_refused() { + let _ = GuardedBuf::new(0); + } + + /// A length past capacity panics instead of silently truncating — + /// in release builds too, which a `debug_assert` would not cover. + #[test] + #[should_panic(expected = "exceeds guarded capacity")] + fn slice_past_capacity_panics() { + let buf = GuardedBuf::new(16); + let _ = buf.as_slice(17); + } + + /// Lock refusal warns without preventing zero-initialization or buffer use. + #[test] + #[tracing_test::traced_test] + fn mlock_refusal_is_detected_and_not_fatal() { + let mut lock_attempted = false; + let mut buf = GuardedBuf::new_with_lock(64, |_ptr, cap| { + assert_eq!(cap, 64); + lock_attempted = true; + false + }); + assert!(lock_attempted); + assert!(logs_contain("secret pages could not be locked into RAM")); + assert_eq!(buf.as_slice(64), &[0; 64]); + buf.as_mut_slice(64).copy_from_slice(&[0x3Cu8; 64]); + assert_eq!(buf.as_slice(64), &[0x3Cu8; 64]); + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/keyring.rs b/packages/rs-platform-wallet-storage/src/secrets/keyring.rs index 618706276e3..a8f573de336 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/keyring.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/keyring.rs @@ -1,57 +1,49 @@ //! OS-keyring construction helper. //! -//! Built on `keyring-core 1.0.0` (the SPI library) plus the -//! per-platform credential-store crates; the `keyring` 4.x sample CLI -//! crate itself is intentionally not a dependency. +//! Built on `keyring-core 1.0.0` (the SPI) plus the per-platform +//! credential-store crates; the `keyring` 4.x sample CLI crate is +//! deliberately not a dependency. There is no crate-local wrapper — +//! [`default_credential_store`]'s return value is used directly via +//! [`keyring_core::api::CredentialStoreApi`] or installed as the process +//! default via [`keyring_core::set_default_store`]. //! -//! There is no crate-local wrapper around the per-platform store: a -//! caller takes [`default_credential_store`]'s return value and either -//! uses it directly via [`keyring_core::api::CredentialStoreApi`] or -//! installs it as the process default via -//! [`keyring_core::set_default_store`]. +//! Threat coverage: protects **A1** (other local user) and **A4** (lost +//! laptop) where the platform encrypts items at rest and scopes them to +//! the user. Does **not** cover **A2/A3** same-user malware (most OS +//! keyrings hand the secret to any same-user process) or **A5** keyring +//! scraping; headless Linux without Secret Service fails closed +//! ([`keyring_core::Error::NoDefaultStore`]), never degrading to plaintext. //! -//! ## Threat coverage +//! Metadata is enumerable plaintext: entries are keyed by `service = +//! SERVICE_PREFIX + hex(wallet_id)` and `user = label`, stored as +//! plaintext keyring metadata. Same-user list-only tooling can enumerate +//! which wallet ids and slot kinds exist without unlocking a secret — +//! dominated by the accepted A2/A3 residual, with no portable knob to +//! redact it. Operators wanting metadata hiding should prefer +//! [`EncryptedFileStore`](super::EncryptedFileStore), whose +//! `(wallet_id, label)` map lives only inside the sealed vault. //! -//! Covers **A1** (other local user) and **A4** (lost laptop) where the -//! platform encrypts keyring items at rest and scopes them to the user. -//! Does **not** cover **A2/A3** same-user malware (most OS keyrings -//! hand the secret to any same-user process that asks), **A5** if the -//! keyring daemon itself is scraped, or **headless Linux** with no -//! Secret Service — that fails closed -//! ([`keyring_core::Error::NoDefaultStore`]), never degrades to -//! plaintext. -//! -//! ### Per-OS reality -//! -//! - **Linux/FreeBSD:** Secret Service (gnome-keyring / KWallet) is the -//! sole backend. It requires a D-Bus session + unlocked collection; -//! headless / SSH / CI boxes frequently lack it, in which case the -//! store fails closed with `NoDefaultStore` and the operator selects -//! [`EncryptedFileStore`](super::EncryptedFileStore) explicitly. -//! Items persist `UntilDelete`. Callers that need durable storage on -//! a headless host should pin -//! [`EncryptedFileStore`](super::EncryptedFileStore) instead. -//! - **macOS:** Keychain ACL — a re-signed binary with the same -//! code-signing identity is an accepted residual risk. Items persist -//! `UntilDelete`. -//! - **Windows:** Credential Manager / DPAPI is user-profile scoped; a -//! same-user process can unprotect it. DPAPI is **not** a defense -//! against same-user malware, only A1/A4. Items persist -//! `UntilDelete`. +//! Per-OS: items persist `UntilDelete` everywhere. Linux/FreeBSD use +//! Secret Service (gnome-keyring / KWallet), which needs a D-Bus session +//! and an unlocked collection. macOS Keychain ACL accepts a re-signed +//! binary with the same code-signing identity (residual). Windows DPAPI +//! is user-profile scoped and defends A1/A4 only, not same-user malware. use std::sync::Arc; use keyring_core::api::CredentialStoreApi; use keyring_core::Error as KeyringError; -/// Open the platform's default credential store, failing closed -/// (typed [`KeyringError::NoDefaultStore`]) when none is reachable -/// (headless / no Secret Service / no D-Bus). Never panics, never -/// falls back to a weaker store. +/// Open the platform's default credential store, failing closed (typed +/// [`KeyringError::NoDefaultStore`]) when none is reachable (headless / no +/// Secret Service / no D-Bus). Never panics, never falls back to a weaker +/// store. The returned `Arc` works with +/// [`keyring_core::set_default_store`] or builds entries directly. /// -/// The returned `Arc` may be passed straight to -/// [`keyring_core::set_default_store`] or used directly to build -/// entries. +/// SPI-direct consumers: format the returned [`KeyringError`] with +/// `Display` (`{}`), **never** `Debug` — upstream `BadEncoding` / +/// `BadDataFormat` variants embed raw bytes in `Debug` (CWE-209/CWE-532). +/// The typed [`SecretStore`](super::SecretStore) path avoids the SPI error. pub fn default_credential_store() -> Result, KeyringError> { platform_default_store() @@ -59,26 +51,27 @@ pub fn default_credential_store() -> Result Result, KeyringError> { - // Secret Service (gnome-keyring / KWallet) is the only OS backend. - // No reachable D-Bus session / unlocked collection (headless, SSH, - // CI) is fail-closed by design — the operator selects - // EncryptedFileStore explicitly instead. + // Secret Service is the only backend; an unreachable D-Bus session + // (headless / SSH / CI) is fail-closed by design. match dbus_secret_service_keyring_store::Store::new() { Ok(s) => Ok(s), - Err(_) => Err(KeyringError::NoDefaultStore), + Err(e) => { + tracing::debug!(error = %e, "secret service keyring init failed; falling back to NoDefaultStore"); + Err(KeyringError::NoDefaultStore) + } } } #[cfg(target_os = "macos")] fn platform_default_store() -> Result, KeyringError> { - // `apple-native-keyring-store` >= 1.0 with the `keychain` feature - // exposes `Store` under the `keychain` module, not at the crate - // root (sibling backends — `dbus-secret-service-keyring-store`, - // `windows-native-keyring-store` — do put `Store` at the root, hence - // the asymmetric path). + // `apple-native-keyring-store` >= 1.0 exposes `Store` under the + // `keychain` module, not the crate root like the sibling backends. match apple_native_keyring_store::keychain::Store::new() { Ok(s) => Ok(s), - Err(_) => Err(KeyringError::NoDefaultStore), + Err(e) => { + tracing::debug!(error = %e, "keychain keyring init failed; falling back to NoDefaultStore"); + Err(KeyringError::NoDefaultStore) + } } } @@ -86,7 +79,10 @@ fn platform_default_store() -> Result, fn platform_default_store() -> Result, KeyringError> { match windows_native_keyring_store::Store::new() { Ok(s) => Ok(s), - Err(_) => Err(KeyringError::NoDefaultStore), + Err(e) => { + tracing::debug!(error = %e, "dpapi keyring init failed; falling back to NoDefaultStore"); + Err(KeyringError::NoDefaultStore) + } } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs index f44699f6ae7..217a7ff54e3 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs @@ -1,65 +1,50 @@ //! Out-of-band storage for wallet secret material (mnemonic / seed / //! xpriv), kept entirely off the SQLite persister's data path. //! -//! # Consumer entry point: [`SecretStore`] -//! -//! [`SecretStore`] is the public, never-leaking front door. Its read -//! path ([`SecretStore::get`]) yields a zeroizing [`SecretBytes`] — a raw -//! `Vec` never crosses this boundary — and its write path -//! ([`SecretStore::set`]) takes `&SecretBytes`, so a caller cannot pass an -//! unwrapped buffer. Errors surface as the typed [`SecretStoreError`], -//! losslessly for the file arm (`WrongPassphrase` vs `Corruption` vs -//! `AlreadyLocked` stay distinct). -//! -//! - [`SecretStore::file`] — Argon2id + XChaCha20-Poly1305 vault file. -//! Recommended on **headless / server** hosts; fully self-contained. -//! - [`SecretStore::os`] — the platform OS keyring, fail-closed on -//! headless Linux. Recommended on **desktop**. -//! -//! # Internal SPI -//! -//! Below `SecretStore`, the backend SPI is upstream's -//! [`keyring_core::api::CredentialStoreApi`] / [`CredentialApi`]. -//! [`EncryptedFileStore`] and [`default_credential_store`] expose that -//! SPI directly; their `keyring_core::Error` projection is **lossy and -//! string-only** (the typed distinction lives on the `SecretStore` path). -//! Consumers should prefer `SecretStore`. -//! -//! - [`SecretBytes`] / [`SecretString`] — zeroize-on-drop wrappers. -//! - [`SecretStoreError`] — the typed error returned by `SecretStore` -//! and both backends, projected into `keyring_core::Error` for the SPI. +//! Consumers use [`SecretStore`], the public never-leaking front door: +//! reads yield a zeroizing [`SecretBytes`] (a raw `Vec` never crosses +//! the boundary), writes take `&SecretBytes`, and errors are the typed +//! [`SecretStoreError`] (lossless on the file arm). Pick a backend +//! explicitly — [`SecretStore::file`] (Argon2id + XChaCha20-Poly1305 +//! vault, headless/server) or [`SecretStore::os`] (OS keyring, desktop; +//! fail-closed on headless Linux). There is no silent fallback. +//! +//! Below `SecretStore` the backend SPI is upstream's +//! [`keyring_core::api::CredentialStoreApi`] / [`CredentialApi`], exposed +//! directly by [`EncryptedFileStore`] / [`default_credential_store`]; +//! its `keyring_core::Error` projection is lossy and string-only, so +//! consumers should prefer `SecretStore`. //! //! [`CredentialApi`]: keyring_core::api::CredentialApi //! [`CredentialStoreApi`]: keyring_core::api::CredentialStoreApi //! -//! Everything secret-bearing lives under this `src/secrets/` tree by -//! design: `tests/secrets_scan.rs` scans only `src/sqlite/schema/` + -//! `migrations/` and exempts this module, so this module owns its own -//! review discipline (`tests/secrets_guard.rs`). -//! -//! # Memory hygiene -//! -//! At the SPI seam the upstream `get_secret` returns `Vec`; -//! [`SecretStore::get`] wraps it via [`SecretBytes::new`] **immediately** -//! (no named intermediate `Vec` binding) so the bare buffer's window is -//! zero statements: `SecretBytes::new` moves the `Vec` into a -//! `Zeroizing>` without copying. -//! -//! # Backend selection +//! This `src/secrets/` tree is the sole secret-bearing module: +//! `tests/secrets_scan.rs` exempts it, so it owns its own review +//! discipline via `tests/secrets_guard.rs`. //! -//! Selection is an explicit operator decision — there is no silent -//! fallback between the file vault and the OS keyring. +//! Cryptographic wire format lives in [`mod@wire`]: the Tier-2 +//! envelope (`wire::envelope`) and the three AAD constructions +//! (`wire::aad`) are bincode-encoded against a single `WIRE_CONFIG`, so +//! a future bincode-config drift is caught by the golden-vector tests +//! in `wire::envelope::tests` rather than silently corrupting every +//! stored blob. mod error; mod file; +mod guarded; mod keyring; mod secret; mod store; mod validate; +mod wire; pub use error::{IoError, OsKeyringErrorKind, SecretStoreError}; -pub use file::{EncryptedFileCredential, EncryptedFileStore, MAX_VAULT_SIZE_BYTES, SERVICE_PREFIX}; +pub use file::{ + EncryptedFileCredential, EncryptedFileStore, MAX_SECRET_LEN, MAX_VAULT_SIZE_BYTES, + SERVICE_PREFIX, +}; pub use keyring::default_credential_store; -pub use secret::{SecretBytes, SecretString}; +pub use secret::{SecretBytes, SecretString, MAX_PASSPHRASE_LEN, MIN_PASSPHRASE_LEN}; pub use store::SecretStore; pub use validate::WalletId; +pub use wire::envelope::MAX_PLAINTEXT_LEN; diff --git a/packages/rs-platform-wallet-storage/src/secrets/secret.rs b/packages/rs-platform-wallet-storage/src/secrets/secret.rs index 6a2a593af33..ef34ef8eac0 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/secret.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/secret.rs @@ -1,33 +1,81 @@ //! Zeroizing secret wrappers: [`SecretString`] for UTF-8 secrets and //! [`SecretBytes`] for byte secrets (seeds, xprivs, KDF output, AEAD //! keys, decrypted plaintext). Both have a redacting `Debug`, no -//! `Display`/`Deref`/`Serialize`, a full buffer wipe on drop, and a -//! best-effort `region` mlock (CWE-316). +//! `Display`/`Deref`/`Serialize`, a full buffer wipe on drop, and live in +//! guard-paged, `mlock`ed memory (CWE-316) supplied by +//! [`GuardedBuf`](super::guarded::GuardedBuf). +//! +//! Each secret owns its data pages outright, so no two live secrets share +//! a page and freeing one can never unlock memory another still holds. +//! The cost is at least a page per secret, which is what sizes +//! [`MAX_SECRET_LEN`](super::MAX_SECRET_LEN) against a constrained +//! `RLIMIT_MEMLOCK`. use std::fmt; use subtle::ConstantTimeEq; -use zeroize::{Zeroize, Zeroizing}; +use zeroize::Zeroize; + +use super::guarded::GuardedBuf; /// Pre-allocation capacity for [`SecretString`] buffers. /// -/// `mlock` is page-granular, so a sub-page buffer locks a whole page -/// regardless; 4096 bytes also makes `String` reallocation (which -/// leaves an un-zeroed freed buffer the allocator owns) virtually -/// impossible for any human-entered passphrase or mnemonic. -const DEFAULT_CAPACITY: usize = 4096; +/// `memsec` prefixes every allocation with a 16-byte canary and rounds +/// the total up to whole pages, so 4080 bytes is the largest payload that +/// still fits a single data page on a **4 KiB-page** host — ample for any +/// passphrase or 24-word mnemonic, and one page is the minimum a guarded +/// allocation can cost regardless. +/// +/// Sized against 4 KiB rather than +/// [`ASSUMED_PAGE_SIZE`](super::guarded::ASSUMED_PAGE_SIZE) on purpose: +/// this value is what *every* [`SecretString`] pre-allocates, so raising +/// it to fill a 16 KiB page would quadruple the real locked cost of every +/// passphrase on the 4 KiB hosts that dominate deployment, to buy +/// capacity nothing asks for. The locked-memory budget already charges +/// this row a full page either way. +/// +/// A guarded page is also the minimum a *non-empty* secret can cost, so a +/// 32-byte key occupies one whole locked page. That overhead is inherent +/// to memsec's page-granular isolation and is accepted as the price of the +/// no-shared-page guarantee; the only case worth avoiding is the empty +/// one, which allocates nothing at all. +const DEFAULT_CAPACITY: usize = 4096 - 16; + +/// Minimum post-trim byte length for a vault passphrase or Tier-2 password. +/// +/// This defense-in-depth floor rejects trivially short inputs but is not a +/// strength estimator. Dictionary checks, UX feedback, and the real entropy +/// policy remain the consumer's responsibility (see `SECRETS.md`). +/// +/// **One-way.** Read paths gate on it, so it may only ever be LOWERED. +// INTENTIONAL(read-gates-follow-write-side-tunables): raising this locks out +// every vault and every Tier-2 secret enrolled under a shorter passphrase, +// permanently — `open`, `rekey` and `unwrap_password_payload` all reject below +// it, with no override and no legacy door. `MAX_SECRET_LEN` and +// `MAX_VAULT_SIZE_BYTES` are one-way in the same sense, downward. Accepted +// rather than split into policy/wire pairs the way `ARGON2_READ_MAX_*` is: no +// shipped build has moved any of the three, and the split buys nothing until +// one of them needs to move. Migrate enrolled data before it does. +pub const MIN_PASSPHRASE_LEN: usize = 8; + +/// Maximum byte length for a vault passphrase or Tier-2 object password. +/// +/// Sized to fit one guarded page on any supported host. +/// Passphrases are held resident for a store's whole lifetime and up to +/// three are live at once during a re-protect, so this ceiling is what +/// keeps them a fixed one-page row in the locked-memory budget documented +/// at [`MAX_SECRET_LEN`](crate::secrets::MAX_SECRET_LEN) instead of an +/// unbounded one. Far above any human-typed passphrase. +pub const MAX_PASSPHRASE_LEN: usize = DEFAULT_CAPACITY; /// Zeroize-on-drop wrapper for secret UTF-8 strings (BIP-39 mnemonic, /// `EncryptedFileStore` passphrase). /// -/// `Display`, `Deref`, `DerefMut`, `Serialize`, `PartialEq`, `Eq` are -/// intentionally **not** implemented; read access is the explicit -/// [`expose_secret`] only, and equality goes through -/// [`subtle::ConstantTimeEq`] (`==` on secret bytes is forbidden, no -/// exception, so future bridge code cannot inherit a non-constant-time -/// path). `Debug` is redacted. `Zeroizing` -/// wipes the buffer over its full capacity on drop; the buffer is -/// best-effort `mlock`ed against swap. +/// Read access is [`expose_secret`] only; equality goes through +/// [`subtle::ConstantTimeEq`] (`==` is forbidden so bridge code cannot +/// inherit a non-constant-time path). `Display`/`Deref`/`Serialize`/`Eq` +/// are deliberately absent, `Debug` is redacted, and the buffer wipes +/// over its full capacity on drop and lives in guarded, `mlock`ed pages. /// /// [`expose_secret`]: SecretString::expose_secret /// @@ -37,82 +85,248 @@ const DEFAULT_CAPACITY: usize = 4096; /// let b = SecretString::new("pw"); /// let _ = a == b; // `==` on SecretString is forbidden; use ConstantTimeEq::ct_eq /// ``` +#[derive(Default)] pub struct SecretString { - // Field order is load-bearing: `inner` drops (and `Zeroizing` wipes - // it) before `_lock` releases the page, so the buffer is wiped while - // still mlock'ed. - inner: Zeroizing, - _lock: Option, + /// `None` for an empty secret: a guarded allocation costs a whole + /// page, which is pure waste when there is nothing to protect. + buf: Option, + /// Byte length of the UTF-8 plaintext held at the start of `buf`. + len: usize, } impl SecretString { - /// Wrap a string, copying it into a capacity-padded buffer, - /// zeroizing the source, and best-effort `mlock`ing the buffer. + /// Wrap a string, copying it into guarded memory and zeroizing the + /// source so no unprotected copy outlives the call. pub fn new(s: impl Into) -> Self { let mut source: String = s.into(); - let cap = source.len().max(DEFAULT_CAPACITY); - let mut buf = String::with_capacity(cap); - buf.push_str(&source); + let secret = Self::from_plaintext(&source); + // Do not remove: wipes the moved-in plaintext source before it + // drops. A direct freed-buffer scan would be a use-after-free, so + // the test `secret_string_new_zeroizes_string_source` pins the + // `String::zeroize` primitive and this call site instead. source.zeroize(); - let lock = region::lock(buf.as_ptr(), buf.capacity()) - .map_err(|e| { - tracing::warn!( - "mlock failed for SecretString; secret may be swappable to disk: {e}" - ); - e - }) - .ok(); + secret + } + + /// Copy `text` straight into a fresh guarded buffer, with no + /// intermediate unprotected allocation. Empty text allocates nothing. + fn from_plaintext(text: &str) -> Self { + if text.is_empty() { + return Self::default(); + } + let mut buf = GuardedBuf::new(text.len().max(DEFAULT_CAPACITY)); + buf.as_mut_slice(text.len()) + .copy_from_slice(text.as_bytes()); Self { - inner: Zeroizing::new(buf), - _lock: lock, + buf: Some(buf), + len: text.len(), } } - /// An empty, capacity-padded, locked buffer. + /// An empty secret, holding no allocation. pub fn empty() -> Self { Self::default() } /// Borrow the plaintext. The only read path. + /// + /// # Panics + /// + /// Panics if the buffer does not hold valid UTF-8. Only whole `&str` + /// values are ever written into it, so that signals a bug in this + /// module rather than a recoverable condition. pub fn expose_secret(&self) -> &str { - &self.inner + let Some(buf) = &self.buf else { + return ""; + }; + std::str::from_utf8(buf.as_slice(self.len)).expect("SecretString holds valid UTF-8") } /// Secret length in bytes. pub fn len(&self) -> usize { - self.inner.len() + self.len } /// Whether the secret is empty. pub fn is_empty(&self) -> bool { - self.inner.is_empty() + self.len == 0 } /// A new `SecretString` holding the whitespace-trimmed content, /// keeping the trimmed copy inside the wrapper. pub fn trimmed(&self) -> Self { - Self::new(self.inner.trim().to_string()) + Self::from_plaintext(self.expose_secret().trim()) } -} -impl Default for SecretString { - fn default() -> Self { - let s = String::with_capacity(DEFAULT_CAPACITY); - let lock = region::lock(s.as_ptr(), s.capacity()) - .map_err(|e| { - tracing::warn!( - "mlock failed for SecretString; secret may be swappable to disk: {e}" - ); - e - }) - .ok(); - Self { - inner: Zeroizing::new(s), - _lock: lock, + /// Whether the secret is empty or all Unicode-whitespace. + /// + /// Returns only blank-ness — never a borrowed view of the plaintext — + /// and uses [`str::trim`] (the Unicode `White_Space` property), so a + /// NBSP (`U+00A0`) trims to blank but a ZWSP (`U+200B`, not + /// `White_Space`) does not. Minimum-length enforcement uses + /// [`is_below_minimum_passphrase_len`](Self::is_below_minimum_passphrase_len) + /// instead. Always available — **not** feature-gated. + pub fn is_blank(&self) -> bool { + self.expose_secret().trim().is_empty() + } + + /// Replace the plaintext bytes in `range` with `replacement`, growing + /// the guarded buffer when it does not fit. + /// + /// The type's single mutation primitive, mirroring + /// [`String::replace_range`]: insertion is an empty range, deletion an + /// empty `replacement`, whole-buffer replacement `..`. Bytes vacated + /// by a shrinking edit are wiped rather than merely orphaned past the + /// length, and a buffer outgrown by a growing edit is wiped before it + /// is freed. Byte offsets, not character indices — derive them from + /// [`expose_secret`](Self::expose_secret). + /// + /// Capacity only grows: a shrinking edit keeps its allocation, since + /// giving it back would cost another copy and another lock cycle for + /// no security gain, and the trailing capacity is wiped regardless. + /// + /// # Panics + /// + /// Panics if `range` is inverted, ends past [`len`](Self::len), or has + /// an endpoint off a UTF-8 character boundary — each a caller bug, as + /// for [`String::replace_range`]. The secret is left unmodified, and + /// the panic message names only indices, never a byte of plaintext. + /// + /// # Memory + /// + /// No length ceiling is applied here: a value type cannot report a + /// refusal, and both real trust boundaries already enforce one — the + /// UI that accepts the input, and + /// [`MAX_PLAINTEXT_LEN`](crate::secrets::MAX_PLAINTEXT_LEN) at the + /// vault write. The consequence is that growth driven by untrusted + /// input (a paste into a text field) is unbounded `mlock`ed, + /// page-rounded memory, and the locks fail open once `RLIMIT_MEMLOCK` + /// runs out. Crossing [`MAX_PASSPHRASE_LEN`] emits a warning containing + /// only lengths; callers must still bound such input at their own boundary. + /// + /// ``` + /// use platform_wallet_storage::secrets::SecretString; + /// let mut s = SecretString::new("hello"); + /// s.replace_range(5.., " world"); + /// assert_eq!(s.expose_secret(), "hello world"); + /// ``` + pub fn replace_range>(&mut self, range: R, replacement: &str) { + let (start, end) = self.resolve_range(range); + let replacement = replacement.as_bytes(); + let old_len = self.len; + // `resolve_range` guarantees `start <= end <= old_len`. + let new_len = old_len - (end - start) + replacement.len(); + if old_len <= MAX_PASSPHRASE_LEN && new_len > MAX_PASSPHRASE_LEN { + tracing::warn!( + length = new_len, + maximum = MAX_PASSPHRASE_LEN, + "secret string grew beyond the store passphrase ceiling" + ); + } + self.reserve(new_len); + + let Some(buf) = &mut self.buf else { + // `reserve` allocates whenever `new_len > 0`, so an absent + // buffer means the edit was a no-op on an empty secret. + return; + }; + let bytes = buf.as_mut_slice(old_len.max(new_len)); + bytes.copy_within(end..old_len, start + replacement.len()); + bytes[start..start + replacement.len()].copy_from_slice(replacement); + // A shrinking edit leaves the old tail above the new length; wipe + // it now rather than wait for an overwrite that may never come. + if new_len < old_len { + bytes[new_len..old_len].zeroize(); + } + self.len = new_len; + } + + /// Resolve `range` against the live plaintext, panicking on any shape + /// [`String::replace_range`] would reject. + fn resolve_range>(&self, range: R) -> (usize, usize) { + use std::ops::Bound; + let start = match range.start_bound() { + Bound::Included(&i) => i, + Bound::Excluded(&i) => i.saturating_add(1), + Bound::Unbounded => 0, + }; + let end = match range.end_bound() { + Bound::Included(&i) => i.saturating_add(1), + Bound::Excluded(&i) => i, + Bound::Unbounded => self.len, + }; + check_edit_range(self.expose_secret(), start, end); + (start, end) + } + + /// Grow the buffer to hold at least `needed` bytes, preserving the + /// live plaintext. A no-op when it already fits. + fn reserve(&mut self, needed: usize) { + let capacity = self.buf.as_ref().map_or(0, GuardedBuf::capacity); + if needed == 0 || needed <= capacity { + return; + } + let mut grown = + GuardedBuf::new(needed.max(capacity.saturating_mul(2)).max(DEFAULT_CAPACITY)); + if let Some(old) = &self.buf { + grown + .as_mut_slice(self.len) + .copy_from_slice(old.as_slice(self.len)); } + // Assigning drops the outgrown buffer, wiping it before + // `memsec::free` hands its pages back. + self.buf = Some(grown); + } + + /// Whether the trimmed secret is shorter than [`MIN_PASSPHRASE_LEN`]. + pub(crate) fn is_below_minimum_passphrase_len(&self) -> bool { + self.expose_secret().trim().len() < MIN_PASSPHRASE_LEN + } + + /// Whether the secret is longer than [`MAX_PASSPHRASE_LEN`]. + /// + /// Untrimmed, unlike the floor: the whole value occupies guarded + /// pages whether or not its edges are whitespace, and it is the page + /// cost this ceiling exists to bound. + pub(crate) fn exceeds_maximum_passphrase_len(&self) -> bool { + self.len > MAX_PASSPHRASE_LEN } } +/// Reject an inverted, out-of-bounds, or non-character-boundary edit +/// range over `text`. +/// +/// **Every message carries indices only.** Slicing `text` to let std +/// raise the error instead — `&text[start..end]`, `str::split_at`, +/// `String::replace_range` — would print the surrounding characters, +/// which here are plaintext, onto stderr and into every log capture +/// (CWE-209/CWE-532). That is why the bounds are hand-rolled from +/// [`str::is_char_boundary`], and why a refactor back to slicing would +/// be a vulnerability rather than a simplification. +/// +/// Takes the text rather than a `SecretString` so it never calls +/// `expose_secret` itself, keeping `tests/secrets_guard.rs`'s +/// sink-near-plaintext scan honest instead of merely quiet. +fn check_edit_range(text: &str, start: usize, end: usize) { + assert!( + start <= end, + "secret edit range start {start} exceeds end {end}" + ); + assert!( + end <= text.len(), + "secret edit range end {end} exceeds secret length {}", + text.len() + ); + assert!( + text.is_char_boundary(start), + "secret edit range start {start} is not a character boundary" + ); + assert!( + text.is_char_boundary(end), + "secret edit range end {end} is not a character boundary" + ); +} + impl fmt::Debug for SecretString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("SecretString(***)") @@ -120,9 +334,8 @@ impl fmt::Debug for SecretString { } impl ConstantTimeEq for SecretString { - /// Constant-time compare over the equal-length region. Unequal - /// lengths return `0` without revealing where they differ; the - /// only observable is the (non-secret) length difference. + /// Constant-time compare. Unequal lengths return `0` without + /// revealing where they differ; the only leak is the non-secret length. fn ct_eq(&self, other: &Self) -> subtle::Choice { self.expose_secret() .as_bytes() @@ -130,6 +343,17 @@ impl ConstantTimeEq for SecretString { } } +impl Zeroize for SecretString { + /// Wipe the buffer in place on a live value. `Drop` runs the same + /// wipe automatically; this lets a holder zeroize early. + fn zeroize(&mut self) { + if let Some(buf) = &mut self.buf { + buf.zeroize_all(); + } + self.len = 0; + } +} + impl From for SecretString { fn from(s: String) -> Self { Self::new(s) @@ -137,23 +361,129 @@ impl From for SecretString { } impl From<&str> for SecretString { + /// Copies straight into guarded memory, with no transient `String`. fn from(s: &str) -> Self { - Self::new(s.to_string()) + Self::from_plaintext(s) + } +} + +/// Deserialize a UTF-8 secret (a vault passphrase or a Tier-2 object +/// password arriving via config) straight into guarded memory, so no +/// intermediate plaintext buffer **we own** lingers (CWE-316). +/// +/// Gated behind the default-off `serde` feature, which gates the IMPL and +/// not the dep — `secrets` already compiles serde for the vault format, so +/// a consumer that has not asked for this simply does not get it. There is +/// deliberately **no** `Serialize` companion (a secret is read-from-config, +/// never written back / round-tripped / logged), so this type cannot leak +/// out through serde under any feature combination. +/// +/// **Residual (documented, not closeable here):** the deserializer's own +/// input buffer holds the cleartext before this visitor runs and is +/// outside `SecretString`'s ownership, so it cannot be wiped here — feed +/// secrets from a zeroizing source. Mirrors the Argon2 `Block` residual +/// noted at `crypto::derive_key`. +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for SecretString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + /// Refuse a value past [`MAX_PASSPHRASE_LEN`] before it reaches + /// guarded memory. + /// + /// Config is the one construction path this crate does not + /// control the size of, and an over-long value here would lock + /// page-rounded memory that the budget at `MAX_SECRET_LEN` does + /// not account for. Reports the length only — never the value. + fn reject_oversized(len: usize) -> Result<(), E> { + if len > MAX_PASSPHRASE_LEN { + return Err(E::invalid_length( + len, + &"a secret within MAX_PASSPHRASE_LEN", + )); + } + Ok(()) + } + + struct SecretStringVisitor; + + impl<'v> serde::de::Visitor<'v> for SecretStringVisitor { + type Value = SecretString; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a secret string") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + reject_oversized(v.len())?; + // Copy the borrowed bytes straight into guarded memory — + // no owned `String` is created, so none can linger. + Ok(SecretString::from_plaintext(v)) + } + + fn visit_string(self, mut v: String) -> Result + where + E: serde::de::Error, + { + if let Err(e) = reject_oversized(v.len()) { + // The rejected value is still an unprotected copy we + // own; wipe it rather than let the error path drop it + // intact. + v.zeroize(); + return Err(e); + } + // `SecretString::new` zeroizes the moved-in `String`. + Ok(SecretString::new(v)) + } + } + + deserializer.deserialize_string(SecretStringVisitor) + } +} + +/// Render the JSON schema as a plain `string` carrying **no** length or +/// value policy: no `minLength`/`maxLength`/`pattern`/`format` (would leak +/// a length policy) and no `example`/`default` (would embed a value) +/// A short, value-free `description` marks sensitivity. +/// +/// Unconditional under `secrets`: the schema carries neither a policy nor +/// a value, so there is nothing to opt out of. Pulls in no +/// `Serialize`/`Display` path. +impl schemars::JsonSchema for SecretString { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("SecretString") + } + + fn schema_id() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("platform_wallet_storage::secrets::SecretString") + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "description": "A secret string. Write-only: never serialized, never echoed." + }) } } /// Zeroize-on-drop wrapper for secret **bytes**: BIP-32 seed -/// (`[u8; 64]`), xpriv, Argon2 output, AEAD key, decrypted plaintext, -/// ciphertext-in-flight. +/// (`[u8; 64]`), xpriv, Argon2 output, AEAD key, decrypted plaintext. +/// +/// `Clone` is absent to force deliberate copies (move it, or +/// `expose_secret()` into another wrapper). Equality goes through +/// [`subtle::ConstantTimeEq`] only (`==` is forbidden so bridge code +/// cannot inherit a non-constant-time path). `Display`/`Deref`/`Serialize` +/// /`Eq` are absent, `Debug` is redacted, and the buffer wipes on drop +/// and lives in guarded, `mlock`ed pages. /// -/// Not `Copy`; `Clone` is intentionally absent to enforce copy -/// minimization — move it, or `expose_secret()` and copy -/// deliberately into another wrapper. `Display`, `Deref`, `Serialize`, -/// `PartialEq`, `Eq` are intentionally **not** implemented; equality -/// goes through [`subtle::ConstantTimeEq`] only (`==` on secret bytes is -/// forbidden, no exception, so future bridge code cannot inherit a -/// non-constant-time path). `Debug` is redacted; the -/// buffer is wiped on drop and best-effort `mlock`ed. +/// Unlike [`SecretString`] the buffer is sized exactly to the secret: +/// this type never grows, and key material is small and known, so padding +/// every 32-byte key out to a full page would buy nothing. An empty +/// `SecretBytes` holds no allocation at all. /// /// ```compile_fail /// use platform_wallet_storage::secrets::SecretBytes; @@ -162,91 +492,103 @@ impl From<&str> for SecretString { /// let _ = a == b; // `==` on SecretBytes is forbidden; use ConstantTimeEq::ct_eq /// ``` pub struct SecretBytes { - // Field order is load-bearing: `inner` drops (and `Zeroizing` wipes - // it) before `_lock` releases the page, so the buffer is wiped while - // still mlock'ed. - inner: Zeroizing>, - _lock: Option, + /// `None` for an empty secret: a guarded allocation costs a whole + /// page, which is pure waste when there is nothing to protect. + buf: Option, + len: usize, } impl SecretBytes { - /// Wrap a byte vector, moving it into the wrapper and best-effort - /// `mlock`ing the buffer. - pub fn new(bytes: Vec) -> Self { - // Lock only a non-empty allocation: an empty `Vec`'s `as_ptr()` - // is dangling, and `region::lock` rejects a 0-length region. - let lock = if bytes.capacity() > 0 { - region::lock(bytes.as_ptr(), bytes.capacity()) - .map_err(|e| { - tracing::warn!( - "mlock failed for SecretBytes; secret may be swappable to disk: {e}" - ); - e - }) - .ok() - } else { - None - }; - // The move transfers ownership of the allocation into - // `Zeroizing`; the source buffer is not copied, so there is - // nothing left behind to wipe. - Self { - inner: Zeroizing::new(bytes), - _lock: lock, - } + /// Wrap a byte vector, copying it into guarded memory and zeroizing + /// the source. + /// + /// The copy is unavoidable: guarded memory comes from a dedicated + /// allocator, so a `Vec`'s own allocation can never *become* the + /// protected buffer. Wiping `bytes` is therefore load-bearing — the + /// caller's plaintext would otherwise be left on the ordinary heap. + pub fn new(mut bytes: Vec) -> Self { + let secret = Self::from_slice(&bytes); + // Do not remove: without this the general-purpose heap keeps an + // unprotected copy of every secret that passes through here. + bytes.zeroize(); + secret } - /// A zeroed buffer of `len` bytes, best-effort `mlock`ed — for - /// in-place fills (KDF output, decrypt target). + /// A zeroed buffer of `len` bytes in guarded memory — for in-place + /// fills (KDF output, decrypt target). pub fn zeroed(len: usize) -> Self { - Self::new(vec![0u8; len]) + Self { + buf: (len > 0).then(|| GuardedBuf::new(len)), + len, + } } /// Copy a borrowed slice into a fresh wrapper. Deliberate, explicit /// copy — the only way to duplicate secret bytes. pub fn from_slice(bytes: &[u8]) -> Self { - Self::new(bytes.to_vec()) + let mut secret = Self::zeroed(bytes.len()); + secret.expose_secret_mut().copy_from_slice(bytes); + secret } /// Borrow the plaintext bytes. The only read path. pub fn expose_secret(&self) -> &[u8] { - &self.inner + match &self.buf { + Some(buf) => buf.as_slice(self.len), + None => &[], + } } /// Mutably borrow the plaintext bytes (in-place KDF/decrypt fill). pub fn expose_secret_mut(&mut self) -> &mut [u8] { - &mut self.inner + let len = self.len; + match &mut self.buf { + Some(buf) => buf.as_mut_slice(len), + None => &mut [], + } } /// Secret length in bytes. pub fn len(&self) -> usize { - self.inner.len() + self.len } /// Whether the secret is empty. pub fn is_empty(&self) -> bool { - self.inner.is_empty() + self.len == 0 } } impl ConstantTimeEq for SecretBytes { - /// Fixed-width constant-time compare over the byte region — no - /// length early-return. `subtle::ConstantTimeEq` on - /// unequal-length slices yields `0` without leaking *where* they - /// differ; the only observable is the (non-secret) length. + /// Constant-time compare, no length early-return. Unequal lengths + /// yield `0` without leaking *where* they differ; only the non-secret + /// length is observable. fn ct_eq(&self, other: &Self) -> subtle::Choice { - self.inner.as_slice().ct_eq(other.inner.as_slice()) + self.expose_secret().ct_eq(other.expose_secret()) + } +} + +impl Zeroize for SecretBytes { + /// Wipe the buffer in place on a live value. `Drop` runs the same + /// wipe automatically; this lets a holder zeroize early. + fn zeroize(&mut self) { + if let Some(buf) = &mut self.buf { + buf.zeroize_all(); + } + self.len = 0; } } impl fmt::Debug for SecretBytes { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "SecretBytes([REDACTED; {}])", self.inner.len()) + write!(f, "SecretBytes([REDACTED; {}])", self.len) } } #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::*; #[test] @@ -264,6 +606,34 @@ mod tests { assert_eq!(s.trimmed().expose_secret(), "abandon ability"); } + /// Two sound checks (the moved-in source is gone by the time `new` + /// returns, so scanning it would be a use-after-free): (1) + /// `String::zeroize` empties a buffer — the primitive `new` relies on; + /// (2) `new` copies the content into the wrapper faithfully. That + /// `new` actually calls `source.zeroize()` is pinned by the + /// do-not-remove comment at that call site, not asserted here. + #[test] + fn secret_string_new_zeroizes_string_source() { + let mut source = String::from("super secret seed material"); + source.zeroize(); + assert!(source.is_empty(), "String::zeroize must empty the source"); + let s = SecretString::new(String::from("super secret seed material")); + assert_eq!(s.expose_secret(), "super secret seed material"); + } + + /// The `SecretBytes::new` counterpart. Guarded memory cannot adopt a + /// `Vec`'s allocation, so `new` copies and must wipe the original; + /// without that an unprotected duplicate of every secret would stay on + /// the ordinary heap. Pinned the same way as the `String` source. + #[test] + fn secret_bytes_new_zeroizes_vec_source() { + let mut source = vec![0xABu8; 64]; + source.zeroize(); + assert!(source.is_empty(), "Vec::zeroize must empty the source"); + let b = SecretBytes::new(vec![0xABu8; 64]); + assert_eq!(b.expose_secret(), &[0xABu8; 64]); + } + #[test] fn secret_string_ct_eq_is_value_based() { // Equality goes through `ConstantTimeEq` only. @@ -281,6 +651,226 @@ mod tests { assert_eq!(SecretString::default().len(), 0); } + /// Mirrors `empty_secret_bytes_constructs_without_allocating`: the two + /// sibling wrappers must answer "what does an empty secret cost?" the + /// same way. `open_unprotected` holds an empty passphrase resident for + /// a whole store's lifetime, so a padded buffer here would lock a page + /// per keyless vault to protect nothing. + #[test] + fn empty_secret_string_constructs_without_allocating() { + for s in [ + SecretString::empty(), + SecretString::default(), + SecretString::new(""), + SecretString::from(""), + ] { + assert!(s.is_empty()); + assert_eq!(s.expose_secret(), ""); + assert!(s.buf.is_none(), "an empty secret must hold no allocation"); + } + // Wiping a populated secret keeps its allocation; only + // construction decides whether one exists. + let mut s = SecretString::new("something worth protecting"); + s.zeroize(); + assert!(s.is_empty()); + assert!(s.buf.is_some()); + } + + /// The passphrase ceiling counts raw bytes, not trimmed ones — the + /// whole value occupies the guarded pages the ceiling bounds. + #[test] + fn passphrase_ceiling_boundary_is_inclusive() { + let at_cap = SecretString::new("x".repeat(MAX_PASSPHRASE_LEN)); + let over = SecretString::new("x".repeat(MAX_PASSPHRASE_LEN + 1)); + let padded = SecretString::new(format!("{} ", "x".repeat(MAX_PASSPHRASE_LEN - 1))); + assert!(!at_cap.exceeds_maximum_passphrase_len()); + assert!(over.exceeds_maximum_passphrase_len()); + assert!( + padded.exceeds_maximum_passphrase_len(), + "whitespace still costs guarded bytes, so it counts" + ); + } + + /// `Send`/`Sync` exercised for real, not just asserted: a secret is + /// built on one thread, moved to another, read and dropped there, + /// while a second secret is shared by reference across two more. + #[test] + fn secrets_cross_thread_boundaries() { + let moved = SecretString::new("moved across a thread boundary"); + let handle = std::thread::spawn(move || { + assert_eq!(moved.expose_secret(), "moved across a thread boundary"); + assert_eq!(moved.len(), 30); + // Dropped here: the wipe and free run off the allocating thread. + }); + handle.join().expect("moved secret must survive the send"); + + let shared = std::sync::Arc::new(SecretBytes::from_slice(&[0x7Eu8; 64])); + let readers: Vec<_> = (0..2) + .map(|_| { + let shared = std::sync::Arc::clone(&shared); + std::thread::spawn(move || { + assert!(bool::from( + shared.ct_eq(&SecretBytes::from_slice(&[0x7Eu8; 64])) + )); + }) + }) + .collect(); + for reader in readers { + reader.join().expect("shared secret must survive the share"); + } + assert_eq!(shared.expose_secret(), &[0x7Eu8; 64]); + } + + /// Multi-byte content survives the copy into guarded memory intact. + /// The buffer is raw bytes now, so a botched length would corrupt + /// UTF-8 and trip `expose_secret`'s validity check. + #[test] + fn secret_string_handles_multibyte_utf8() { + let text = "héllo wörld 🦡"; + let s = SecretString::new(text); + assert_eq!(s.expose_secret(), text); + assert_eq!(s.len(), text.len()); + assert!(s.len() > text.chars().count(), "multi-byte chars expected"); + } + + /// A secret larger than the padded default still round-trips: the + /// buffer sizes to the content instead of truncating it. + #[test] + fn secret_string_larger_than_default_capacity() { + let long = "x".repeat(DEFAULT_CAPACITY + 1234); + let s = SecretString::new(long.clone()); + assert_eq!(s.expose_secret(), long); + assert_eq!(s.len(), DEFAULT_CAPACITY + 1234); + } + + /// `is_blank()` truth table. The boundary deliberately + /// exercises Unicode whitespace — `str::trim` uses the `White_Space` + /// property, so NBSP (`U+00A0`) trims to blank but ZWSP (`U+200B`, + /// not `White_Space`) does not. + #[test] + fn is_blank_truth_table() { + // Blank inputs. + assert!(SecretString::empty().is_blank()); + assert!(SecretString::new("").is_blank()); + assert!(SecretString::new(" ").is_blank()); + assert!(SecretString::new("\t\r\n ").is_blank()); + assert!( + SecretString::new("\u{00A0}").is_blank(), + "NBSP is White_Space" + ); + // Non-blank inputs. + assert!(!SecretString::new("pw").is_blank()); + assert!(!SecretString::new(" pw ").is_blank()); + assert!( + !SecretString::new("\u{200B}").is_blank(), + "ZWSP is NOT White_Space" + ); + } + + /// `is_blank` returns a `bool` and exposes no borrowed + /// plaintext, callable with only `secrets` (no serde/schemars). + #[test] + fn is_blank_signature_returns_bool_no_borrow() { + let f: fn(&SecretString) -> bool = SecretString::is_blank; + assert!(f(&SecretString::new(""))); + assert!(!f(&SecretString::new("x"))); + } + + /// `SecretString` must never implement + /// `Serialize` or `Display`, even with serde compiled in. This is a + /// compile-time `!impl` assertion — adding either impl breaks the + /// build. `serde::Serialize` is nameable here because `secrets` always + /// pulls the `serde` dep. + #[test] + fn secret_string_has_no_serialize_no_display() { + static_assertions::assert_not_impl_any!(SecretString: serde::Serialize, std::fmt::Display); + } + + /// Regression: the `serde` DEP is on under `secrets`, yet the + /// `Deserialize` IMPL stays ABSENT because the `serde` FEATURE gates + /// the impl rather than the dep — proving the default-off gate is + /// satisfiable even while serde is compiled. + #[cfg(not(feature = "serde"))] + #[test] + fn deserialize_absent_without_the_serde_feature_even_though_the_dep_is_on() { + static_assertions::assert_not_impl_any!( + SecretString: serde::de::DeserializeOwned + ); + } + + /// With the `serde` feature on, the `Deserialize` impl is present (and + /// `Serialize` is still absent — see the always-on test). + #[cfg(feature = "serde")] + #[test] + fn deserialize_present_with_the_serde_feature() { + static_assertions::assert_impl_all!(SecretString: serde::de::DeserializeOwned); + static_assertions::assert_not_impl_any!(SecretString: serde::Serialize); + } + + /// `Deserialize` routes the value into guarded memory; the result + /// `ct_eq`s a directly-built secret and has the right length. + #[cfg(feature = "serde")] + #[test] + fn deserialize_routes_value_through_zeroizing_constructor() { + let s: SecretString = serde_json::from_str("\"correct horse battery staple\"").unwrap(); + assert!(bool::from( + s.ct_eq(&SecretString::new("correct horse battery staple")) + )); + assert_eq!(s.len(), 28); + } + + /// Config is untrusted input, so `Deserialize` refuses a value past + /// [`MAX_PASSPHRASE_LEN`] before it reaches guarded memory — and the + /// error message carries the length only, never the value. + #[cfg(feature = "serde")] + #[test] + fn deserialize_rejects_oversized_value() { + let at_cap = format!("\"{}\"", "a".repeat(MAX_PASSPHRASE_LEN)); + let s: SecretString = serde_json::from_str(&at_cap).expect("the cap itself is accepted"); + assert_eq!(s.len(), MAX_PASSPHRASE_LEN); + + let over = format!("\"{}\"", "z".repeat(MAX_PASSPHRASE_LEN + 1)); + let err = serde_json::from_str::(&over) + .expect_err("a value past the cap must be refused"); + let rendered = err.to_string(); + assert!( + rendered.contains(&(MAX_PASSPHRASE_LEN + 1).to_string()), + "{rendered}" + ); + assert!( + !rendered.contains("zzz"), + "error leaked the value: {rendered}" + ); + } + + /// `JsonSchema` renders a plain `string` and leaks no + /// length/value policy — no `minLength`/`maxLength`/`pattern`/`format`, + /// no `example`/`default`/`enum`. + #[test] + fn json_schema_is_plain_string_no_policy_leak() { + let schema = schemars::schema_for!(SecretString); + let v = serde_json::to_value(&schema).unwrap(); + assert_eq!(v["type"], serde_json::json!("string")); + for forbidden in [ + "minLength", + "maxLength", + "pattern", + "format", + "example", + "default", + "enum", + ] { + assert!( + v.get(forbidden).is_none(), + "schema leaked `{forbidden}`: {v}" + ); + } + // Any description present must carry no example/secret value. + if let Some(desc) = v.get("description").and_then(|d| d.as_str()) { + assert!(!desc.contains("horse")); + } + } + #[test] fn secret_bytes_debug_redacted() { let b = SecretBytes::from_slice(&[1, 2, 3, 4, 5]); @@ -299,16 +889,17 @@ mod tests { } #[test] - fn empty_secret_bytes_constructs_without_mlocking_dangling_ptr() { - // A capacity-0 `Vec` has a dangling `as_ptr()`; `new` must not - // pass it to `region::lock`. Constructing must not panic and the - // wrapper must round-trip as empty. + fn empty_secret_bytes_constructs_without_allocating() { + // An empty secret has nothing to protect, so it must not burn a + // guarded page (four pages of mapping) on it. let b = SecretBytes::new(Vec::new()); assert!(b.is_empty()); assert_eq!(b.len(), 0); assert_eq!(b.expose_secret(), &[] as &[u8]); + assert!(b.buf.is_none(), "an empty secret must hold no allocation"); let z = SecretBytes::zeroed(0); assert!(z.is_empty()); + assert!(z.buf.is_none()); } #[test] @@ -336,53 +927,376 @@ mod tests { assert!(std::mem::needs_drop::()); }; - /// Best-effort runtime check that `Drop` wipes the full `SecretString` - /// capacity. Reads freed memory — UB in the strict sense, flaky under - /// parallelism; run single-threaded: - /// `cargo test --features secrets -- secret_string_drop_zeroes --ignored --test-threads=1` - #[test] - #[ignore] - fn secret_string_drop_zeroes_full_capacity() { - let ptr: *const u8; - let cap: usize; - { - let s = SecretString::new("sensitive_seed_material"); - ptr = s.inner.as_ptr(); - cap = s.inner.capacity(); - // SAFETY: live allocation, read for `cap` bytes pre-drop. - #[allow(unsafe_code)] - let pre = unsafe { std::slice::from_raw_parts(ptr, cap) }; - assert!(pre.iter().any(|&b| b != 0)); + /// Both wrappers are `Send + Sync`. `GuardedBuf` owns the raw pointer + /// and the `unsafe impl`s that earn these, so losing them here would + /// silently break cross-thread holders. + #[test] + fn secret_wrappers_stay_send_and_sync() { + static_assertions::assert_impl_all!(SecretString: Send, Sync); + static_assertions::assert_impl_all!(SecretBytes: Send, Sync); + } + + /// The structural guarantee: no two live secrets touch a common page. + /// + /// A shared page means one secret's lifetime governs its neighbour's + /// anti-swap protection — freeing the first unlocks memory the second + /// still holds. Guarded allocation makes that impossible rather than + /// merely survivable, which is the point of the design. This fails on + /// a `String`/`Vec`-backed layout, where the general-purpose allocator + /// packs several secrets into one page. + #[test] + fn secrets_never_share_a_page() { + let page = region::page::size(); + let strings: Vec = (0..32) + .map(|i| SecretString::new(format!("s{i}"))) + .collect(); + let bytes: Vec = (0..32) + .map(|i| SecretBytes::from_slice(&[i as u8; 48])) + .collect(); + + // (start address, payload capacity, label) for every live buffer. + let mut regions: Vec<(usize, usize, String)> = Vec::new(); + for (i, s) in strings.iter().enumerate() { + let buf = s.buf.as_ref().expect("a non-empty secret is allocated"); + regions.push((buf.addr(), buf.capacity(), format!("SecretString {i}"))); + } + for (i, b) in bytes.iter().enumerate() { + let buf = b.buf.as_ref().expect("a 48-byte secret is allocated"); + regions.push((buf.addr(), buf.capacity(), format!("SecretBytes {i}"))); } - // SAFETY: best-effort post-free read; single-thread makes page - // reuse before this read unlikely. - #[allow(unsafe_code)] - let post = unsafe { std::slice::from_raw_parts(ptr, cap) }; - assert!(post.iter().all(|&b| b == 0), "buffer not zeroed on drop"); - } - - /// Best-effort runtime check that `Drop` wipes `SecretBytes`. Same - /// caveat as above; run single-threaded with `--ignored`. A - /// page-sized buffer is used so the allocator is unlikely to reuse - /// the freed page before the post-drop read (a tiny `Vec` would be - /// recycled immediately, making the check meaningless). - #[test] - #[ignore] - fn secret_bytes_drop_zeroes() { - let ptr: *const u8; - let cap: usize; - { - let b = SecretBytes::from_slice(&[0xAB; 4096]); - ptr = b.inner.as_ptr(); - cap = b.inner.capacity(); - // SAFETY: live allocation, read for `cap` bytes pre-drop. - #[allow(unsafe_code)] - let pre = unsafe { std::slice::from_raw_parts(ptr, cap) }; - assert!(pre.iter().any(|&x| x != 0)); + + let mut owner: HashMap = HashMap::new(); + for (start, cap, label) in regions { + assert_eq!( + (start + cap) % page, + 0, + "{label}: the buffer must end on a page boundary, where memsec's guard page begins" + ); + for page_index in (start / page)..=((start + cap - 1) / page) { + if let Some(previous) = owner.insert(page_index, label.clone()) { + panic!("{previous} and {label} share page {page_index:#x}"); + } + } } - // SAFETY: best-effort post-free read; see note above. - #[allow(unsafe_code)] - let post = unsafe { std::slice::from_raw_parts(ptr, cap) }; - assert!(post.iter().all(|&x| x == 0), "buffer not zeroed on drop"); + } + + /// Insertion, deletion and replacement in place, all within the + /// pre-allocated capacity so no reallocation is involved. + #[test] + fn replace_range_inserts_deletes_and_replaces() { + let mut s = SecretString::new("bcd"); + s.replace_range(0..0, "a"); + assert_eq!(s.expose_secret(), "abcd"); + s.replace_range(4..4, "e"); + assert_eq!(s.expose_secret(), "abcde"); + s.replace_range(2..2, "XY"); + assert_eq!(s.expose_secret(), "abXYcde"); + + s.replace_range(2..4, ""); + assert_eq!(s.expose_secret(), "abcde"); + s.replace_range(1..4, "-"); + assert_eq!(s.expose_secret(), "a-e"); + assert_eq!(s.len(), 3); + } + + /// One differential case: a label, the edit applied to `String`, and the + /// same edit applied to `SecretString`. + type DifferentialRangeCase = ( + &'static str, + Box, + Box, + ); + + /// One rejected-range case: a label and the edit expected to panic. + type InvalidRangeCase = (&'static str, Box); + + /// Every `RangeBounds` shape resolves the way `String::replace_range` + /// resolves it — differential, so the contract is pinned to std's + /// rather than to this implementation's own behaviour. + #[test] + fn replace_range_bounds_match_std() { + let cases: Vec = vec![ + ( + "..", + Box::new(|s: &mut String| s.replace_range(.., "Z")), + Box::new(|s: &mut SecretString| s.replace_range(.., "Z")), + ), + ( + "2..", + Box::new(|s: &mut String| s.replace_range(2.., "Z")), + Box::new(|s: &mut SecretString| s.replace_range(2.., "Z")), + ), + ( + "..3", + Box::new(|s: &mut String| s.replace_range(..3, "Z")), + Box::new(|s: &mut SecretString| s.replace_range(..3, "Z")), + ), + ( + "1..=3", + Box::new(|s: &mut String| s.replace_range(1..=3, "Z")), + Box::new(|s: &mut SecretString| s.replace_range(1..=3, "Z")), + ), + ( + "..=2", + Box::new(|s: &mut String| s.replace_range(..=2, "Z")), + Box::new(|s: &mut SecretString| s.replace_range(..=2, "Z")), + ), + ]; + for (label, on_std, on_secret) in cases { + let mut std_string = String::from("abcdef"); + let mut secret = SecretString::new("abcdef"); + on_std(&mut std_string); + on_secret(&mut secret); + assert_eq!( + secret.expose_secret(), + std_string, + "range `{label}` diverged" + ); + } + } + + /// A shrinking edit wipes the bytes it vacates instead of merely + /// orphaning them past the length — otherwise a deleted passphrase + /// character would sit in locked memory until the next overwrite. + #[test] + fn replace_range_wipes_the_bytes_it_vacates() { + let mut s = SecretString::new("secret-tail-KEEPOUT"); + let cap = s.buf.as_ref().unwrap().capacity(); + s.replace_range(6.., ""); + assert_eq!(s.expose_secret(), "secret"); + + let buf = s.buf.as_ref().unwrap(); + assert!( + buf.as_slice(cap)[6..].iter().all(|&b| b == 0), + "the vacated tail must be wiped, not orphaned past the length" + ); + } + + /// Multi-byte characters survive splices on both sides of a 2-byte + /// and a 4-byte character; a botched offset would corrupt UTF-8 and + /// trip `expose_secret`'s validity check. + #[test] + fn replace_range_handles_multibyte_utf8() { + // "é" is 2 bytes, "🦡" is 4. + let mut s = SecretString::new("aébc🦡d"); + assert_eq!(s.len(), 1 + 2 + 2 + 4 + 1); + s.replace_range(1..3, "É"); + assert_eq!(s.expose_secret(), "aÉbc🦡d"); + s.replace_range(5..9, "🦘"); + assert_eq!(s.expose_secret(), "aÉbc🦘d"); + s.replace_range(0..1, "ααα"); + assert_eq!(s.expose_secret(), "αααÉbc🦘d"); + } + + /// Growth past the pre-allocated capacity preserves the content — + /// both as one large edit and as the long run of small appends a + /// text widget actually produces. + #[test] + fn replace_range_grows_past_capacity() { + let long = "x".repeat(DEFAULT_CAPACITY + 500); + let mut one_shot = SecretString::new("seed:"); + one_shot.replace_range(5.., &long); + // Built in its own statement: `tests/secrets_guard.rs` rejects a + // formatting sink sharing a statement with `expose_secret`. + let expected = format!("seed:{long}"); + assert_eq!(one_shot.expose_secret(), expected); + + // An empty secret holds no allocation, so the first edit is also + // the first allocation. + let mut typed = SecretString::empty(); + assert!(typed.buf.is_none()); + let mut expected = String::new(); + for i in 0..DEFAULT_CAPACITY + 100 { + let ch = char::from(b'a' + (i % 26) as u8); + let at = typed.len(); + typed.replace_range(at.., &ch.to_string()); + expected.push(ch); + } + assert_eq!(typed.expose_secret(), expected); + } + + /// A buffer outgrown by a reallocation is wiped before it is freed, + /// and the replacement still ends flush against its guard page — the + /// page-isolation invariant must survive growth. + #[test] + fn growth_wipes_the_old_buffer_and_preserves_page_isolation() { + let mut s = SecretString::new("needle-in-guarded-memory"); + let old_addr = s.buf.as_ref().unwrap().addr(); + let old_cap = s.buf.as_ref().unwrap().capacity(); + + s.replace_range(s.len().., &"y".repeat(DEFAULT_CAPACITY)); + let buf = s.buf.as_ref().unwrap(); + assert_ne!(buf.addr(), old_addr, "the edit must have reallocated"); + assert!(buf.capacity() > old_cap); + + let page = region::page::size(); + assert_eq!( + (buf.addr() + buf.capacity()) % page, + 0, + "a grown buffer must still abut its guard page" + ); + // The outgrown allocation was wiped by `GuardedBuf::drop` before + // `memsec::free` returned its pages; reading it back would be a + // use-after-free, so `Drop`'s wipe is pinned by + // `zeroize_wipes_full_capacity_not_just_len` on a live value and + // by this reallocation going through the same `Drop`. + let expected = format!("needle-in-guarded-memory{}", "y".repeat(DEFAULT_CAPACITY)); + assert_eq!(s.expose_secret(), expected); + } + + /// Each rejected range shape panics, matching `String::replace_range`. + #[test] + fn replace_range_panics_on_invalid_ranges() { + let cases: Vec = vec![ + ( + "inverted", + // The inversion is the case under test — `String::replace_range` + // panics on it, and so must this. Reversing the range would + // delete the scenario. + #[expect(clippy::reversed_empty_ranges, reason = "the case under test")] + Box::new(|s: &mut SecretString| s.replace_range(3..1, "")), + ), + ( + "end past len", + Box::new(|s: &mut SecretString| s.replace_range(0..99, "")), + ), + ( + "start off a char boundary", + Box::new(|s: &mut SecretString| s.replace_range(2..4, "")), + ), + ( + "end off a char boundary", + Box::new(|s: &mut SecretString| s.replace_range(1..2, "")), + ), + ]; + for (label, edit) in cases { + // "é" occupies bytes 1..3, so 2 is mid-character. + let mut s = SecretString::new("aéb"); + let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| edit(&mut s))); + assert!(caught.is_err(), "`{label}` must panic"); + } + } + + /// The regression guard for CWE-209/CWE-532: a rejected range must + /// not print the secret. `str`'s own slicing panic embeds a snippet + /// of the surrounding string, so reaching the panic through + /// `&s[range]` — the obvious implementation — would put plaintext on + /// stderr and into every log capture. + #[test] + fn replace_range_panic_message_carries_no_plaintext() { + // Bound as a literal, never read back out of the secret: pairing + // `expose_secret` with an assertion here would trip + // `tests/secrets_guard.rs`. + let plaintext = "correct-horse-battery-staple"; + let mut s = SecretString::new(plaintext); + + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let payload = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + s.replace_range(0..9_999, "") + })) + .expect_err("an out-of-range edit must panic"); + std::panic::set_hook(previous); + + let message = payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|m| (*m).to_string())) + .expect("panic payload must be a string"); + assert!( + !message.contains(plaintext) && !message.contains("horse"), + "panic message leaked the secret: {message}" + ); + assert!( + message.contains("9999"), + "panic message should name the offending index: {message}" + ); + } + + /// Validation runs before the first byte moves, so a caught panic + /// leaves the secret exactly as it was — a `catch_unwind`-ing GUI + /// host must never observe a half-spliced, invalid-UTF-8 buffer. + #[test] + fn a_rejected_edit_leaves_the_secret_untouched() { + let mut s = SecretString::new("aéb"); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + s.replace_range(2..3, "zzz") + })); + std::panic::set_hook(previous); + + assert!(caught.is_err()); + assert_eq!(s.expose_secret(), "aéb"); + assert_eq!(s.len(), 4); + } + + /// An empty replacement into an empty range is a no-op, including on + /// an empty secret that holds no allocation at all. + #[test] + fn replace_range_empty_into_empty_is_a_no_op() { + let mut empty = SecretString::empty(); + empty.replace_range(0..0, ""); + assert_eq!(empty.expose_secret(), ""); + assert!( + empty.buf.is_none(), + "a no-op edit must not allocate a guarded page" + ); + + let mut s = SecretString::new("unchanged"); + s.replace_range(4..4, ""); + assert_eq!(s.expose_secret(), "unchanged"); + } + + /// Proves zeroize wipes the buffer. Every read is on a STILL-LIVE + /// value (no post-free deref / UB); the in-place slice wipe also + /// proves the bytes go to zero with the length preserved. + #[test] + fn manual_zeroize_wipes_live_buffer() { + let mut b = SecretBytes::from_slice(&[0xABu8; 64]); + assert!(b.expose_secret().iter().any(|&x| x != 0)); + b.expose_secret_mut().zeroize(); + assert_eq!(b.len(), 64, "in-place wipe must preserve length"); + assert!( + b.expose_secret().iter().all(|&x| x == 0), + "SecretBytes buffer not zeroed by manual zeroize" + ); + + // SecretBytes wrapper-level zeroize empties the buffer. + let mut b2 = SecretBytes::from_slice(&[0xCDu8; 32]); + b2.zeroize(); + assert!(b2.is_empty(), "SecretBytes::zeroize must empty the buffer"); + + // SecretString wrapper-level zeroize empties the buffer; the + // exposed view holds no residual plaintext. + let mut s = SecretString::new("sensitive_seed_material"); + assert!(!s.is_empty()); + s.zeroize(); + assert!(s.is_empty(), "SecretString::zeroize must empty the buffer"); + assert_eq!(s.expose_secret(), ""); + } + + /// The wipe clears the WHOLE buffer, not just the bytes below the old + /// length. Guarded allocation is what makes this directly checkable — + /// the buffer is still alive and inspectable through a safe accessor, + /// where a freed `String` allocation could only be scanned via + /// use-after-free. + #[test] + fn zeroize_wipes_full_capacity_not_just_len() { + let mut s = SecretString::new("sensitive_seed_material"); + s.zeroize(); + let buf = s.buf.as_ref().expect("allocation survives zeroize"); + assert!( + buf.as_slice(buf.capacity()).iter().all(|&b| b == 0), + "zeroize left residue in the buffer's trailing capacity" + ); + + let mut b = SecretBytes::from_slice(&[0xEEu8; 200]); + b.zeroize(); + let buf = b.buf.as_ref().expect("allocation survives zeroize"); + assert!( + buf.as_slice(buf.capacity()).iter().all(|&x| x == 0), + "zeroize left residue in SecretBytes' buffer" + ); } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index b4e75512e9e..1e07955d1dc 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -1,34 +1,33 @@ //! [`SecretStore`] — the public, never-leaking secrets entry point. //! -//! Consumers use this enum, not the `keyring_core` SPI. Its read path -//! ([`SecretStore::get`]) yields a zeroizing [`SecretBytes`]; a raw -//! `Vec` never crosses this boundary, and the write path -//! ([`SecretStore::set`]) takes `&SecretBytes` so a caller cannot pass an -//! unwrapped buffer (M-STRONG-TYPES). -//! -//! Errors surface as the typed [`SecretStoreError`] — losslessly for the -//! [`SecretStore::File`] arm (so `WrongPassphrase` vs `Corruption` vs -//! `AlreadyLocked` stay distinct), and as a best-effort projection of -//! `keyring_core::Error` for the [`SecretStore::Os`] arm. The internal -//! `keyring_core::api::CredentialApi` / `CredentialStoreApi` impls remain -//! the backend SPI; `SecretStore` delegates through them. +//! Consumers use this enum, not the `keyring_core` SPI it delegates to. +//! Reads yield a zeroizing [`SecretBytes`] and writes take `&SecretBytes` +//! so a raw buffer never crosses the boundary. Errors are the typed +//! [`SecretStoreError`] — lossless on the [`SecretStore::File`] arm, a +//! best-effort projection of `keyring_core::Error` on the +//! [`SecretStore::Os`] arm. use std::sync::Arc; use keyring_core::api::CredentialStoreApi; use keyring_core::{Entry, Error as KeyringError}; +use zeroize::Zeroize; use super::error::{OsKeyringErrorKind, SecretStoreError}; -use super::secret::SecretBytes; +use super::file::crypto::KdfParams; +use super::secret::{SecretBytes, SecretString}; use super::validate::WalletId; -use super::{default_credential_store, EncryptedFileStore, SERVICE_PREFIX}; +use super::wire::envelope; +use super::{default_credential_store, EncryptedFileStore, MAX_SECRET_LEN, SERVICE_PREFIX}; /// A passphrase-or-OS-keyring backed store for wallet secret material. /// -/// The only public read path is [`get`](SecretStore::get), which yields a -/// zeroizing [`SecretBytes`] — a raw `Vec` never crosses this -/// boundary. Backend selection is an explicit operator decision; there is -/// no silent fallback between the two arms. +/// Every read path ([`get`](SecretStore::get), +/// [`get_secret`](SecretStore::get_secret), and the read inside +/// [`reprotect`](SecretStore::reprotect)) yields a zeroizing +/// [`SecretBytes`] — a raw `Vec` never crosses this boundary. Backend +/// selection is an explicit operator decision; there is no silent fallback +/// between the two arms. pub enum SecretStore { /// Self-contained Argon2id + XChaCha20-Poly1305 vault file. /// Recommended on headless / server hosts. @@ -41,7 +40,8 @@ impl SecretStore { /// Open (or prepare to create) a file-backed vault at `path`, /// unlocked by `passphrase`. `path` is the vault file itself /// (operator picks the filename); the parent directory is - /// materialized on the first write. + /// materialized on the first write. The trimmed passphrase must contain at + /// least [`MIN_PASSPHRASE_LEN`](super::MIN_PASSPHRASE_LEN) bytes. pub fn file( path: impl AsRef, passphrase: super::SecretString, @@ -49,52 +49,198 @@ impl SecretStore { Ok(Self::File(EncryptedFileStore::open(path, passphrase)?)) } + /// [`file`](SecretStore::file), but a fresh vault and every per-secret + /// Tier-2 wrap use the enforced FLOOR instead of the shipped 64 MiB target. + /// An existing vault still unlocks under the parameters in its header. The + /// floor also applies inside + /// [`set_secret`](SecretStore::set_secret) / + /// [`reprotect`](SecretStore::reprotect). + /// + /// **Test-only.** Swap this in for [`file`](SecretStore::file) at + /// construction and every subsequent call is transparently fast; no other + /// signature changes. A downstream suite driving real end-to-end flows + /// otherwise pays a production-strength KDF per call (#4111). The store is + /// REAL — same wire formats, same AAD binding, same fail-closed reads — + /// merely cheap to attack, so never point it at live secrets. + /// + /// Gated twice: by the `test-util` feature (or `cfg(test)`) at compile + /// time, and by a runtime panic outside debug builds and this crate's own + /// test harness. + /// See [`EncryptedFileStore::open_mock`] for the full rationale. + /// + /// # Panics + /// + /// See [`EncryptedFileStore::open_mock`] — panics rather than hand back a + /// weak-crypto store if `test-util` reaches a release build. + #[cfg(any(test, feature = "test-util"))] + pub fn file_mock( + path: impl AsRef, + passphrase: super::SecretString, + ) -> Result { + Ok(Self::File(EncryptedFileStore::open_mock(path, passphrase)?)) + } + + /// Open (or create) a **deliberately keyless** file-backed vault — the + /// only door that takes no passphrase. It provides neither confidentiality + /// nor authenticity: anyone who can write the file can derive its key, + /// forge a valid vault, and inject a chosen unprotected secret. Use it where + /// the stored secrets carry their own Tier-2 object password, or as a staging + /// step before [`EncryptedFileStore::rekey`] to a real passphrase. + /// [`file`](SecretStore::file) rejects sub-floor passphrases; this is the + /// explicit keyless alternative. + pub fn file_unprotected(path: impl AsRef) -> Result { + Ok(Self::File(EncryptedFileStore::open_unprotected(path)?)) + } + /// Open the platform's default OS keyring, failing closed when none /// is reachable (headless / no Secret Service). + /// + /// # Errors + /// + /// [`SecretStoreError::HostPageSizeExceedsBudget`] if the host cannot + /// honour the locked-memory budget — this arm hands out guarded + /// [`SecretBytes`] exactly like the file arm does. pub fn os() -> Result { + super::guarded::verify_host_page_size()?; Ok(Self::Os(default_credential_store().map_err(map_spi)?)) } - /// Store `secret` under `(service, label)`, overwriting any prior - /// value. Takes `&SecretBytes` so the caller cannot pass an unwrapped - /// buffer; the wrapped bytes are exposed to the SPI only at the last - /// moment. + /// Store `secret` under `(service, label)` UNPROTECTED (Tier-2 + /// scheme-0), overwriting any prior value — a `set_secret(.., None)` + /// wrapper kept for non-breaking back-compat. Takes `&SecretBytes` so + /// the caller cannot pass an unwrapped buffer. pub fn set( &self, service: &WalletId, label: &str, secret: &SecretBytes, + ) -> Result<(), SecretStoreError> { + self.set_secret(service, label, secret, None) + } + + /// Store `secret` under `(service, label)`, overwriting any prior value. + /// + /// `password` selects the protection: `None` writes an unprotected + /// envelope; `Some(pw)` seals the bytes under the object password `pw` + /// (Argon2id + XChaCha20-Poly1305) **before** they reach the backend, so + /// a protected object stays confidential even under a full backend + /// compromise. A password below the minimum length is rejected + /// ([`BlankPassphrase`](SecretStoreError::BlankPassphrase)). + /// + /// **No recovery (availability):** if a protected object's password is + /// lost, the object is permanently unrecoverable — there is no reset + /// path. The UX must state this plainly. + /// + /// **Entropy is the caller's:** a protected object's confidentiality + /// rests entirely on the password's entropy against an offline Argon2id + /// attacker who already holds the backend. This crate enforces only the + /// minimum length; strength estimation and policy are the caller's job. + /// + /// The write is a same-slot overwrite that leaves the prior value intact + /// on a crash: on the `File` arm via the vault's atomic replace; on the + /// `Os` arm via the backend's single-item-replace contract. + /// Add/change/remove flows go through [`reprotect`](SecretStore::reprotect). + pub fn set_secret( + &self, + service: &WalletId, + label: &str, + secret: &SecretBytes, + password: Option<&SecretString>, + ) -> Result<(), SecretStoreError> { + // Wrap above the backend: the backend only ever stores the opaque + // envelope (ciphertext for a protected object). + let blob = envelope::wrap_with_params( + service, + label, + password, + secret.expose_secret(), + self.tier2_params(), + )?; + self.put_raw(service, label, &blob) + } + + /// Argon2 params for the per-secret Tier-2 wrap. The shipped target + /// everywhere except a mock `File` store, which floors it so + /// `set_secret`/`reprotect` stay fast without a new public parameter + /// (#4111). The `Os` arm has no vault of its own, so it keeps the target. + fn tier2_params(&self) -> KdfParams { + match self { + Self::File(s) => s.kdf_params(), + Self::Os(_) => KdfParams::default_target(), + } + } + + /// Store the already-enveloped opaque `blob` under `(service, label)`. + /// The shared write seam under [`set`] and [`set_secret`]. + /// + /// [`set`]: SecretStore::set + fn put_raw( + &self, + service: &WalletId, + label: &str, + blob: &SecretBytes, ) -> Result<(), SecretStoreError> { match self { - // File arm: the inherent typed path — no lossy SPI seam. - // `put_bytes` takes `&SecretBytes` directly, so the - // bare-buffer view never crosses this boundary. - Self::File(s) => s.put_bytes(service, label, secret), + // Inherent typed path — no lossy SPI seam, no bare buffer. + Self::File(s) => s.put_bytes(service, label, blob), Self::Os(store) => { let entry = build_os(store, service, label)?; - entry.set_secret(secret.expose_secret()).map_err(map_spi) + entry.set_secret(blob.expose_secret()).map_err(map_spi) } } } - /// Retrieve the secret stored under `(service, label)`, or `Ok(None)` - /// if absent. The plaintext is wrapped into [`SecretBytes`] at the - /// seam with no named `Vec` intermediate, so the bare-buffer window is - /// zero statements. + /// Retrieve the UNPROTECTED secret stored under `(service, label)`, or + /// `Ok(None)` if absent — a `get_secret(.., None)` wrapper kept for + /// non-breaking back-compat. A scheme-1 (password-protected) object read + /// through this path returns + /// [`NeedsPassword`](SecretStoreError::NeedsPassword); use + /// [`get_secret`](SecretStore::get_secret) with the object password. pub fn get( &self, service: &WalletId, label: &str, + ) -> Result, SecretStoreError> { + self.get_secret(service, label, None) + } + + /// Read the opaque bytes stored under `(service, label)`, or + /// `Ok(None)` if absent — the raw backend value, always a Tier-2 + /// envelope (writes go through + /// [`set_secret`](SecretStore::set_secret)). The typed-vs-SPI + /// distinction is preserved exactly as the pre-Tier-2 path did. This + /// is the shared seam under [`get`] and [`get_secret`]; it does NOT + /// interpret the envelope. + /// + /// [`get`]: SecretStore::get + fn get_raw( + &self, + service: &WalletId, + label: &str, ) -> Result, SecretStoreError> { match self { - // File arm: the inherent typed path keeps `WrongPassphrase` - // vs `Corruption` distinct (lossless). Plaintext rides as - // `SecretBytes` all the way; no rewrap needed. + // Inherent typed path: keeps WrongPassphrase vs Corruption + // distinct; plaintext rides as SecretBytes, no rewrap. Self::File(s) => s.get_bytes(service, label), Self::Os(store) => { let entry = build_os(store, service, label)?; match entry.get_secret() { - Ok(v) => Ok(Some(SecretBytes::new(v))), + Ok(mut v) => { + // Defense-in-depth: reject an oversized backend blob + // before it reaches the envelope parse/derive path. + // The File arm's stored bytes are already capped at + // MAX_SECRET_LEN by `put_bytes`; the Os backend has no + // such ceiling, so cap here. A legitimate envelope + // never exceeds MAX_SECRET_LEN; the overhead is + // headroom. + let cap = MAX_SECRET_LEN + envelope::MAX_ENVELOPE_OVERHEAD; + if v.len() > cap { + let found = v.len(); + v.zeroize(); + return Err(SecretStoreError::SecretTooLarge { found, max: cap }); + } + Ok(Some(SecretBytes::new(v))) + } Err(KeyringError::NoEntry) => Ok(None), Err(e) => Err(map_spi(e)), } @@ -102,18 +248,139 @@ impl SecretStore { } } - /// Delete the secret stored under `(service, label)`. Absent entries - /// are a no-op (`Ok(())`), so deletion is idempotent. - pub fn delete(&self, service: &WalletId, label: &str) -> Result<(), SecretStoreError> { + /// Retrieve the secret under `(service, label)` applying the strict, + /// fail-closed read, or `Ok(None)` if absent. + /// + /// `password` IS the caller's protection assertion — supply `Some(pw)` + /// for an object the caller's trusted model says is protected, `None` + /// otherwise. The expectation lives ONLY here, never in the stored + /// blob (see [`envelope::unwrap`]): + /// + /// - `Some(pw)` + a protected blob → the secret (or + /// [`WrongPassword`](SecretStoreError::WrongPassword) on tag fail); + /// - `Some(pw)` + an unprotected blob → + /// [`ExpectedProtectedButUnsealed`](SecretStoreError::ExpectedProtectedButUnsealed) + /// — a strip/downgrade, refused, no bytes returned; + /// - `None` + a protected blob → + /// [`NeedsPassword`](SecretStoreError::NeedsPassword) (never ciphertext); + /// - `None` + an unprotected blob → the secret. + /// + /// **Documented residual:** an attacker who ALSO rewrites the + /// consumer's trusted DB so the caller passes `None` for a stripped + /// object can still downgrade — out of this library's reach by + /// construction (the protection expectation is the caller's; see + /// `SECRETS.md`). The expectation is NEVER persisted by the library. + pub fn get_secret( + &self, + service: &WalletId, + label: &str, + password: Option<&SecretString>, + ) -> Result, SecretStoreError> { + // Absence is availability-only (deletion = DoS, never injection): + // a missing entry is Ok(None) under either password argument. + let Some(stored) = self.get_raw(service, label)? else { + return Ok(None); + }; + envelope::unwrap(service, label, password, stored.expose_secret()).map(Some) + } + + /// Add / change / remove an object password in one same-slot + /// unwrap→rewrap→overwrite — the canonical re-protection flow. + /// + /// Reads the object under the `current` expectation (so a strip is + /// caught fail-closed before any rewrap), then re-writes it under + /// `new`: + /// - **add:** `current = None`, `new = Some(pw)`; + /// - **change:** `current = Some(old)`, `new = Some(pw_new)`; + /// - **remove:** `current = Some(old)`, `new = None`. + /// + /// An absent object returns [`Err(NoEntry)`][SecretStoreError::NoEntry] — + /// `reprotect` is operational; absence means the caller's protection-status + /// record disagrees with the backend, which is a signal not to be silently + /// dropped. The rewrite is the same-slot overwrite of [`set_secret`], so a + /// crash between the read and the commit leaves the prior value intact + /// and readable under `current`. After a successful call the consumer MUST + /// update its own trusted protection-status record (the protection + /// expectation lives there). + /// + /// **No recovery:** changing or removing requires the `current` + /// password; if it is lost the object cannot be re-protected or read, + /// and is permanently unrecoverable (availability trade-off). + /// + /// **Entropy is the caller's:** the `new` password's entropy is the + /// whole confidentiality guarantee for the re-protected object; this + /// crate enforces only the minimum length, not strength. + /// + /// **Atomicity:** on the `File` arm the read → rewrap → write runs under + /// the store's single lock, so a concurrent `set`/`delete` can't interleave + /// and let this rewrite (built on the bytes read here) clobber a newer + /// value. The `Os` arm is a per-item keyring with no transaction, so its + /// read→write is NOT atomic — a documented residual; serialize reprotect + /// intent at the caller if a concurrent writer is possible there. + pub fn reprotect( + &self, + service: &WalletId, + label: &str, + current: Option<&SecretString>, + new: Option<&SecretString>, + ) -> Result<(), SecretStoreError> { match self { Self::File(s) => { - s.delete_bytes(service, label)?; - Ok(()) + let params = s.kdf_params(); + s.reprotect_bytes(service, label, |stored| { + // Scoped so the old envelope's guarded pages are freed + // before the rewrap allocates the new one. Holding both + // live is what made this the crate's deepest + // locked-memory path; see the budget at `MAX_SECRET_LEN`. + let secret = { + let stored = stored.ok_or(SecretStoreError::NoEntry)?; + envelope::unwrap(service, label, current, stored.expose_secret())? + }; + envelope::wrap_with_params(service, label, new, secret.expose_secret(), params) + }) + } + Self::Os(_) => { + // INTENTIONAL(keyring-reprotect-non-atomic): the OS keyring + // exposes no compare-and-swap, so this get-then-set is not + // atomic. Accepted residual: a crash between the two leaves + // the entry under the OLD passphrase (the set never landed), + // and a concurrent writer's value can be overwritten. The + // File arm gets atomicity from its own rename; there is no + // equivalent primitive to borrow here. + let Some(secret) = self.get_secret(service, label, current)? else { + return Err(SecretStoreError::NoEntry); + }; + self.set_secret(service, label, &secret, new) } + } + } + + /// Pollable durability signal — see + /// [`EncryptedFileStore::durability_uncertain_count`]. `Some(count)` on the + /// `File` arm (writes whose data committed but whose parent-dir fsync was + /// unconfirmed); `None` on the `Os` arm, whose backend owns its own + /// durability and exposes no such signal here. + pub fn durability_uncertain_count(&self) -> Option { + match self { + Self::File(s) => Some(s.durability_uncertain_count()), + Self::Os(_) => None, + } + } + + /// Delete the secret stored under `(service, label)`. + /// + /// Returns `Ok(true)` if a credential was removed, `Ok(false)` if no + /// credential existed under `(service, label)`. Idempotent for callers + /// that don't care — `.delete(...)?;` still discards the bool; + /// race-detecting callers can `match delete()?`. + pub fn delete(&self, service: &WalletId, label: &str) -> Result { + match self { + Self::File(s) => s.delete_bytes(service, label), Self::Os(store) => { let entry = build_os(store, service, label)?; match entry.delete_credential() { - Ok(()) | Err(KeyringError::NoEntry) => Ok(()), + Ok(()) => Ok(true), + Err(KeyringError::NoEntry) => Ok(false), Err(e) => Err(map_spi(e)), } } @@ -123,12 +390,10 @@ impl SecretStore { /// Build the SPI [`Entry`] for `(service, label)` on the OS-keyring arm. /// -/// The reject-not-sanitize label allowlist (`^[A-Za-z0-9._-]{1,64}$`) -/// is enforced here before the call crosses into the OS backend. -/// Different OS keyrings accept, normalize, or reject non-allowlisted -/// bytes inconsistently; enforcing the allowlist at -/// this shim keeps `(service, label)` invariants identical to the -/// `File` arm and across every OS backend. +/// Enforces the label allowlist (`^[A-Za-z0-9._-]{1,64}$`) before the +/// call crosses into the OS backend, so the `(service, label)` invariant +/// stays identical to the `File` arm and across every OS keyring (each +/// accepts / normalizes / rejects non-allowlisted bytes differently). fn build_os( store: &Arc, service: &WalletId, @@ -140,12 +405,9 @@ fn build_os( } impl std::fmt::Debug for SecretStore { - /// Surfaces the backend engine/service identity without exposing any - /// secret material. The `Os` arm reports the SPI - /// `vendor()`/`id()` — non-secret backend tags (e.g. which OS keyring - /// is wired up) — rather than an opaque `Os(..)`. The `File` arm - /// delegates to [`EncryptedFileStore`]'s redacting `Debug` (path - /// only, no key/passphrase). + /// Surfaces the backend identity without any secret material: the `Os` + /// arm reports the SPI `vendor()`/`id()` tags; the `File` arm delegates + /// to [`EncryptedFileStore`]'s redacting `Debug` (path only). fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::File(s) => f.debug_tuple("SecretStore::File").field(s).finish(), @@ -159,27 +421,31 @@ impl std::fmt::Debug for SecretStore { } /// Project an OS-keyring SPI [`KeyringError`] into the typed -/// [`SecretStoreError`] for the [`Os`](SecretStore::Os) arm. -/// -/// The OS keyring has no typed `SecretStoreError` origin, so its variants -/// map best-effort into [`SecretStoreError::OsKeyring`] (carrying only a -/// non-secret discriminant) or the closest existing variant. Secret- -/// bearing keyring variants (`BadEncoding`, `BadDataFormat`) are -/// collapsed to a discriminant — their raw bytes never enter -/// `SecretStoreError`. (The [`File`](SecretStore::File) arm never reaches -/// this projection: it uses the inherent typed path.) +/// [`SecretStoreError`] for the [`Os`](SecretStore::Os) arm. Best-effort: +/// variants map into [`SecretStoreError::OsKeyring`] (non-secret +/// discriminant only) or the closest existing variant; byte-bearing +/// keyring variants are collapsed so their bytes never enter the type. +/// The [`File`](SecretStore::File) arm never reaches this projection. fn map_spi(e: KeyringError) -> SecretStoreError { match e { - KeyringError::NoEntry => SecretStoreError::OsKeyring { - kind: OsKeyringErrorKind::NoEntry, - }, + KeyringError::NoEntry => SecretStoreError::NoEntry, KeyringError::NoStorageAccess(_) => SecretStoreError::OsKeyring { kind: OsKeyringErrorKind::NoStorageAccess, }, KeyringError::NoDefaultStore => SecretStoreError::OsKeyring { kind: OsKeyringErrorKind::NoDefaultStore, }, - KeyringError::Invalid(_, _) => SecretStoreError::InvalidLabel, + // The label rides as the keyring `user` attribute (the reverse + // projection maps `InvalidLabel` back to `Invalid("user", _)`), so + // only a rejected `user` is an invalid label. A rejected service — + // `SERVICE_PREFIX` + 64 hex chars, longer than some backends' caps + // — or any other attribute is a backend constraint, not the + // caller's label; mislabelling it `InvalidLabel` sends the caller to + // "fix" a label that was never wrong. + KeyringError::Invalid(attr, _) if attr == "user" => SecretStoreError::InvalidLabel, + KeyringError::Invalid(_, _) => SecretStoreError::OsKeyring { + kind: OsKeyringErrorKind::Backend, + }, KeyringError::BadStoreFormat(_) | KeyringError::BadEncoding(_) | KeyringError::BadDataFormat(_, _) => SecretStoreError::OsKeyring { @@ -197,13 +463,71 @@ mod tests { use crate::secrets::SecretString; fn file_store(dir: &std::path::Path) -> SecretStore { - SecretStore::file(dir.join("vault.pwsvault"), SecretString::new("pw-correct")).unwrap() + SecretStore::file(secure_vault_path(dir), SecretString::new("pw-correct")).unwrap() + } + + /// Tighten the umask-0002 tempdir (0o775) to 0o700 so it passes the + /// parent-dir perm check, then return a vault path inside it. + fn secure_vault_path(dir: &std::path::Path) -> std::path::PathBuf { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + dir.join("vault.pwsvault") } fn wid(b: u8) -> WalletId { WalletId::from([b; 32]) } + /// The locked-memory budget documented at `MAX_SECRET_LEN` is measured + /// against the code, not asserted in prose. + /// + /// `reprotect` on the `File` arm is the crate's deepest path: it runs a + /// full read and then a rewrap while the caller holds two object + /// passwords. Every row of the table is driven to its ceiling here. + /// The budget only holds because `reprotect` scopes the old envelope + /// away before the rewrap allocates and `wrap_with_params` scopes its + /// derived key away before encoding; each scope held open would add a + /// whole page, eating the headroom the concurrent read needs. + /// + /// Measures *accounted* bytes, not resident ones: the gauge is fed + /// `locked_cost`, which is denominated in `ASSUMED_PAGE_SIZE` pages. + /// So this reads 112 KiB on a 4 KiB-page host whose kernel is really + /// locking 36 KiB. That gap is the budget's deliberate conservatism, + /// not a leak — the figure tracks the largest supported host. + #[test] + fn file_reprotect_peak_matches_the_documented_budget() { + use crate::secrets::guarded::gauge; + use crate::secrets::{MAX_PASSPHRASE_LEN, MAX_PLAINTEXT_LEN}; + + let max_pw = || SecretString::new("p".repeat(MAX_PASSPHRASE_LEN)); + let dir = tempfile::tempdir().unwrap(); + // `file_mock` floors the Argon2 params; the buffers it allocates + // are the same sizes a production store's would be. + let store = SecretStore::file_mock(secure_vault_path(dir.path()), max_pw()).unwrap(); + let secret = SecretBytes::from_slice(&vec![0x5Au8; MAX_PLAINTEXT_LEN]); + let (old_pw, new_pw) = (max_pw(), max_pw()); + store + .set_secret(&wid(1), "seed", &secret, Some(&old_pw)) + .unwrap(); + drop(secret); + + gauge::reset_peak(); + store + .reprotect(&wid(1), "seed", Some(&old_pw), Some(&new_pw)) + .unwrap(); + let peak = gauge::peak(); + + assert_eq!( + peak, + 112 * 1024, + "reprotect peaked at {} KiB; the table at MAX_SECRET_LEN says 112", + peak / 1024 + ); + } + #[test] fn get_returns_secret_bytes_not_vec() { let dir = tempfile::tempdir().unwrap(); @@ -226,34 +550,66 @@ mod tests { } #[test] - fn delete_is_idempotent() { + fn delete_returns_false_on_absent_true_on_present() { let dir = tempfile::tempdir().unwrap(); let s = file_store(dir.path()); - // Absent → Ok, no error. - s.delete(&wid(1), "seed").unwrap(); + // Absent → Ok(false), no error. + assert!(!s.delete(&wid(1), "seed").unwrap()); s.set(&wid(1), "seed", &SecretBytes::from_slice(b"x")) .unwrap(); - s.delete(&wid(1), "seed").unwrap(); + // Present → Ok(true). + assert!(s.delete(&wid(1), "seed").unwrap()); assert!(s.get(&wid(1), "seed").unwrap().is_none()); - // Second delete on the now-absent entry is still Ok. - s.delete(&wid(1), "seed").unwrap(); + // Second delete on the now-absent entry is Ok(false). + assert!(!s.delete(&wid(1), "seed").unwrap()); + } + + #[test] + fn reprotect_absent_returns_no_entry() { + let dir = tempfile::tempdir().unwrap(); + let s = file_store(dir.path()); + let err = s + .reprotect(&wid(1), "seed", None, Some(&SecretString::new("password"))) + .unwrap_err(); + assert!( + matches!(err, SecretStoreError::NoEntry), + "expected NoEntry on absent reprotect, got {err:?}" + ); + } + + #[test] + fn reprotect_rejects_sub_minimum_object_password() { + let dir = tempfile::tempdir().unwrap(); + let s = file_store(dir.path()); + let w = wid(2); + s.set(&w, "seed", &SecretBytes::from_slice(b"original")) + .unwrap(); + + let short = SecretString::new("1234567"); + let err = s + .reprotect(&w, "seed", None, Some(&short)) + .expect_err("seven-byte object password must be rejected"); + assert!(matches!(err, SecretStoreError::BlankPassphrase)); + assert_eq!( + s.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"original" + ); + + let minimum = SecretString::new("12345678"); + s.reprotect(&w, "seed", None, Some(&minimum)) + .expect("eight-byte object password must be accepted"); } #[test] fn wrong_passphrase_surfaces_typed_lossless() { - // Resident-vault model: the passphrase is verified at open() - // time (header verify-token), so a wrong-pass reopen fails at - // open() rather than on the first get(). The typed distinction - // still survives losslessly on the public path. + // Resident-vault model verifies the passphrase at open() (header + // verify-token), so a wrong-pass reopen fails at open(), losslessly. let dir = tempfile::tempdir().unwrap(); file_store(dir.path()) .set(&wid(1), "seed", &SecretBytes::from_slice(b"orig")) .unwrap(); - let err = SecretStore::file( - dir.path().join("vault.pwsvault"), - SecretString::new("pw-wrong"), - ) - .expect_err("wrong pass must fail open"); + let err = SecretStore::file(secure_vault_path(dir.path()), SecretString::new("pw-wrong")) + .expect_err("wrong pass must fail open"); assert!( matches!(err, SecretStoreError::WrongPassphrase), "expected WrongPassphrase, got {err:?}" @@ -266,9 +622,8 @@ mod tests { let s = file_store(dir.path()); s.set(&wid(1), "seed", &SecretBytes::from_slice(b"value")) .unwrap(); - // Corrupt the entry ciphertext while leaving the verify-token - // intact: the passphrase is still correct, so this is corruption, - // not a wrong passphrase. The lossless typed path keeps them apart. + // Corrupt the entry ciphertext but leave the verify-token intact: + // passphrase still correct, so this is Corruption, not WrongPassphrase. let SecretStore::File(ref fs) = s else { unreachable!() }; @@ -291,13 +646,12 @@ mod tests { #[test] fn already_locked_surfaces_typed_lossless() { - // Resident-vault model: a second open() of the same path while - // the first store is alive returns AlreadyLocked. The typed - // distinction survives losslessly on the public path. + // A second open() of a path the first store still holds returns + // AlreadyLocked, losslessly on the public path. let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("vault.pwsvault"); - let _s1 = SecretStore::file(&path, SecretString::new("pw")).unwrap(); - let err = SecretStore::file(&path, SecretString::new("pw")).unwrap_err(); + let path = secure_vault_path(dir.path()); + let _s1 = SecretStore::file(&path, SecretString::new("password")).unwrap(); + let err = SecretStore::file(&path, SecretString::new("password")).unwrap_err(); assert!( matches!(err, SecretStoreError::AlreadyLocked), "got {err:?}" @@ -312,16 +666,10 @@ mod tests { assert!(!dbg.contains("pw-correct")); } - /// The OS-keyring shim must enforce the label allowlist BEFORE - /// handing the value to the OS backend. The per-backend label - /// policies (macOS Keychain vs Windows - /// Credential Manager vs Secret Service) differ in what they accept, - /// normalize, or reject; the shim must keep the `(service, label)` - /// invariant uniform across every arm. - /// - /// A mock `CredentialStoreApi` that panics if its `build()` is - /// invoked proves the bad label never crosses the SPI seam — the - /// shim rejects with `SecretStoreError::InvalidLabel` first. + /// The shim must enforce the label allowlist before reaching the OS + /// backend (per-backend policies differ). A `CredentialStoreApi` that + /// panics on `build()` proves a bad label is rejected with + /// `InvalidLabel` before it ever crosses the SPI seam. #[test] fn build_os_rejects_invalid_label_before_spi() { use std::any::Any; @@ -355,9 +703,8 @@ mod tests { let store: Arc = Arc::new(PanickingStore); let os = SecretStore::Os(store); - // Every operation on the OS arm goes through `build_os`; the - // allowlist rejection MUST fire here, so the panicking SPI is - // never reached. + // Every OS-arm op goes through `build_os`, so the allowlist + // rejection fires before the panicking SPI is reached. for bad in ["lab el", "../escape", "", "a:b", "a/b", "lab\0el"] { let err = os .set(&wid(1), bad, &SecretBytes::from_slice(b"x")) @@ -378,4 +725,899 @@ mod tests { ); } } + + /// `map_spi` only collapses a rejected `user` (label) attribute to + /// `InvalidLabel`; a rejected `service` (or any other attribute) is a + /// backend constraint, not the caller's label. + #[test] + fn map_spi_only_user_invalid_is_label() { + let label = map_spi(KeyringError::Invalid( + "user".to_string(), + "allowlist".to_string(), + )); + assert!(matches!(label, SecretStoreError::InvalidLabel)); + + for attr in ["service", "target", "something-else"] { + let mapped = map_spi(KeyringError::Invalid( + attr.to_string(), + "too long".to_string(), + )); + assert!( + matches!( + mapped, + SecretStoreError::OsKeyring { + kind: OsKeyringErrorKind::Backend + } + ), + "Invalid({attr:?}, _) must map to a backend failure, got {mapped:?}" + ); + } + } + + // ===== Tier-2 strict fail-closed read ===== + // + // Parameterised over BOTH arms. The "attacker who can write the + // backend" is modelled per arm by `Backend::place_raw`: on File it + // re-seals the chosen blob under the resident vault key via `put_bytes` + // (a cold/backup-swap actor could only corrupt → DoS, so the strip + // requires the vault key — the File-arm asymmetry); on Os it overwrites + // the keychain item directly (the bare envelope, no second AEAD — where + // the strip residual bites hardest). The writable Os fixture is the + // upstream `keyring_core::mock::Store` (a raw SPI `set_secret` bypasses + // the envelope), so no bespoke mock is needed. + + use keyring_core::mock; + + /// Pad nonblank fixture labels to the production length floor. + fn test_password(s: &str) -> SecretString { + let mut value = s.to_owned(); + while value.trim().len() < crate::secrets::MIN_PASSPHRASE_LEN { + value.push('-'); + } + SecretString::new(value) + } + + fn protected(w: &WalletId, label: &str, pw: &str, secret: &[u8]) -> Vec { + envelope::wrap_with_params( + w, + label, + Some(&test_password(pw)), + secret, + KdfParams::floor_target(), + ) + .unwrap() + .expose_secret() + .to_vec() + } + + fn unprotected(w: &WalletId, label: &str, secret: &[u8]) -> Vec { + // `params` is unused on the unprotected path. + envelope::wrap_with_params(w, label, None, secret, KdfParams::floor_target()) + .unwrap() + .expose_secret() + .to_vec() + } + + /// A backend under test plus the raw-write hook that plays the + /// backend-write attacker. + struct Backend { + store: SecretStore, + _dir: Option, + mock: Option>, + name: &'static str, + } + + impl Backend { + /// Write `blob` to `(w, label)` as opaque backend bytes (the + /// attacker's primitive / the protected-enrol setup). On Os this is + /// a raw SPI `set_secret` on the shared mock store, bypassing the + /// `SecretStore` envelope layer exactly as a breached keychain write + /// would. + fn place_raw(&self, w: &WalletId, label: &str, blob: &[u8]) { + match (&self.store, &self.mock) { + (SecretStore::File(fs), _) => fs + .put_bytes(w, label, &SecretBytes::from_slice(blob)) + .unwrap(), + (SecretStore::Os(_), Some(mock)) => { + let service = format!("{SERVICE_PREFIX}{}", w.to_hex()); + mock.build(&service, label, None) + .unwrap() + .set_secret(blob) + .unwrap(); + } + _ => unreachable!("os backend must carry its mock"), + } + } + } + + fn file_backend() -> Backend { + let dir = tempfile::tempdir().unwrap(); + let store = file_store(dir.path()); + Backend { + store, + _dir: Some(dir), + mock: None, + name: "File", + } + } + + fn os_backend() -> Backend { + // The upstream in-memory mock store. The clone handed to + // `SecretStore::Os` and the handle kept for raw attacker writes + // share the same backing credentials by `Arc`. + let mock = mock::Store::new().unwrap(); + let store = SecretStore::Os(mock.clone()); + Backend { + store, + _dir: None, + mock: Some(mock), + name: "Os", + } + } + + /// The strict-read quadrant. + fn run_quadrant(b: &Backend) { + let w = wid(1); + let pw = SecretString::new("object-pw"); + + // scheme-0 + None → bytes (the ONLY byte-returning quadrant). + b.place_raw(&w, "u0", &unprotected(&w, "u0", b"plain-seed")); + assert_eq!( + b.store + .get_secret(&w, "u0", None) + .unwrap() + .unwrap() + .expose_secret(), + b"plain-seed", + "[{}] scheme-0 + None", + b.name + ); + + // scheme-1 + None → NeedsPassword (never ciphertext). + b.place_raw(&w, "p1", &protected(&w, "p1", "object-pw", b"real-seed")); + assert!( + matches!( + b.store.get_secret(&w, "p1", None).unwrap_err(), + SecretStoreError::NeedsPassword + ), + "[{}] scheme-1 + None", + b.name + ); + + // scheme-1 + Some(correct) → secret. + assert_eq!( + b.store + .get_secret(&w, "p1", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"real-seed", + "[{}] scheme-1 + Some(correct)", + b.name + ); + + // scheme-1 + Some(wrong) → WrongPassword. + assert!( + matches!( + b.store + .get_secret(&w, "p1", Some(&test_password("nope"))) + .unwrap_err(), + SecretStoreError::WrongPassword + ), + "[{}] scheme-1 + Some(wrong)", + b.name + ); + + // scheme-0 + Some(pw) → ExpectedProtectedButUnsealed (fail closed). + assert!( + matches!( + b.store.get_secret(&w, "u0", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + ), + "[{}] scheme-0 + Some", + b.name + ); + + // Truncated envelope (below the bincode minimum) → Corruption, + // both with and without a password — no magic byte to peek at. + b.place_raw(&w, "broken", &[0x01]); + for arg in [None, Some(&pw)] { + assert!( + matches!( + b.store.get_secret(&w, "broken", arg).unwrap_err(), + SecretStoreError::Corruption + ), + "[{}] truncated envelope ({:?})", + b.name, + arg.map(|_| "Some") + ); + } + + // Raw, non-envelope bytes → Corruption under either password + // arg: every read goes through the bincode decoder. + b.place_raw(&w, "raw", b"raw-bytes-not-a-valid-envelope"); + for arg in [None, Some(&pw)] { + assert!( + matches!( + b.store.get_secret(&w, "raw", arg).unwrap_err(), + SecretStoreError::Corruption + ), + "[{}] raw non-envelope bytes ({:?})", + b.name, + arg.map(|_| "Some") + ); + } + + // absent entry → Ok(None) under either arg (deletion = DoS). + assert!(b.store.get_secret(&w, "absent", None).unwrap().is_none()); + assert!(b + .store + .get_secret(&w, "absent", Some(&pw)) + .unwrap() + .is_none()); + } + + #[test] + fn l1_quadrant_file() { + run_quadrant(&file_backend()); + } + + #[test] + fn l1_quadrant_os() { + run_quadrant(&os_backend()); + } + + /// The non-vacuous strip-injection regression. The single + /// test the whole feature exists to make pass. + fn run_strip_injection(b: &Backend) { + let w = wid(2); + let pw = SecretString::new("object-pw"); + + // Enrol protected: stored = a valid scheme-1 envelope of S_real. + b.place_raw( + &w, + "seed", + &protected(&w, "seed", "object-pw", b"REAL-SEED-S_real"), + ); + assert_eq!( + b.store + .get_secret(&w, "seed", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"REAL-SEED-S_real", + "[{}] legit protected read", + b.name + ); + + // Attacker overwrites the slot with a fresh, internally-valid + // scheme-0 envelope carrying a DIFFERENT seed S_evil. + let attacker_blob = unprotected(&w, "seed", b"EVIL-SEED-S_evil"); + b.place_raw(&w, "seed", &attacker_blob); + + // A password-supplied read of the stripped slot fails closed; + // S_evil is NEVER returned. + let err = b.store.get_secret(&w, "seed", Some(&pw)).unwrap_err(); + assert!( + matches!(err, SecretStoreError::ExpectedProtectedButUnsealed), + "[{}] strip must fail closed, got {err:?}", + b.name + ); + + // Non-vacuity: the attacker blob IS a valid unprotected envelope + // that WOULD decode to S_evil under `None` — so the refusal above is + // caused SOLELY by the Some(pw)+scheme-0 strict rule, not by any + // malformation (without the strict rule, S_evil would be returned). + let would_be = envelope::unwrap(&w, "seed", None, &attacker_blob).unwrap(); + assert_eq!( + would_be.expose_secret(), + b"EVIL-SEED-S_evil", + "[{}] non-vacuity: blob decodes to S_evil under None", + b.name + ); + } + + #[test] + fn l1_strip_injection_file() { + run_strip_injection(&file_backend()); + } + + #[test] + fn l1_strip_injection_os() { + run_strip_injection(&os_backend()); + } + + /// A consumer bug alone fails closed in BOTH directions. + fn run_both_det_bug_directions(b: &Backend) { + let w = wid(3); + let pw = test_password("pw"); + // (a) over-supply a password on a genuinely unprotected object. + b.place_raw(&w, "u", &unprotected(&w, "u", b"x")); + assert!(matches!( + b.store.get_secret(&w, "u", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + // (b) under-supply on a genuinely protected object. + b.place_raw(&w, "p", &protected(&w, "p", "pw", b"y")); + assert!(matches!( + b.store.get_secret(&w, "p", None).unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn l1_both_det_bug_directions_file() { + run_both_det_bug_directions(&file_backend()); + } + + #[test] + fn l1_both_det_bug_directions_os() { + run_both_det_bug_directions(&os_backend()); + } + + /// The expectation is NEVER inferred from the blob's scheme + /// byte — identical scheme-1 blobs diverge solely on the password arg. + fn run_expectation_not_inferred(b: &Backend) { + let w = wid(4); + let pw = test_password("pw"); + let blob = protected(&w, "a", "pw", b"seed"); + b.place_raw(&w, "a", &blob); + b.place_raw(&w, "b", &blob); + assert_eq!( + b.store + .get_secret(&w, "a", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"seed" + ); + assert!(matches!( + b.store.get_secret(&w, "b", None).unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn l1_expectation_not_inferred_file() { + run_expectation_not_inferred(&file_backend()); + } + + #[test] + fn l1_expectation_not_inferred_os() { + run_expectation_not_inferred(&os_backend()); + } + + /// Unprotected→protected upgrade confusion is availability- + /// only, fail-closed (NeedsPassword), no leak / no injection. + fn run_upgrade_confusion(b: &Backend) { + let w = wid(5); + b.place_raw(&w, "x", &protected(&w, "x", "attacker-pw", b"whatever")); + assert!(matches!( + b.store.get_secret(&w, "x", None).unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn l1_upgrade_confusion_file() { + run_upgrade_confusion(&file_backend()); + } + + #[test] + fn l1_upgrade_confusion_os() { + run_upgrade_confusion(&os_backend()); + } + + /// A scheme-flip from `Password` → `Unprotected`: `Some(pw)` is + /// caught by the strict rule regardless; `None` reads the body as + /// scheme-0 opaque bytes (never the real seed) — a known residual, + /// dominated by the consumer-DB residual; pinned, not "fixed". + fn run_scheme_flip(b: &Backend) { + use crate::secrets::wire::config::WIRE_CONFIG; + use crate::secrets::wire::envelope::{Envelope, Payload}; + + let w = wid(6); + let pw = test_password("pw"); + let blob = protected(&w, "x", "pw", b"real-seed"); + let (env, _): (Envelope, usize) = bincode::decode_from_slice(&blob, WIRE_CONFIG).unwrap(); + let flipped = match env.payload { + Payload::Password { ciphertext, .. } => Envelope { + version: env.version, + payload: Payload::Unprotected(ciphertext), + }, + Payload::Unprotected(_) => panic!("protected() must yield a Password payload"), + }; + let flipped_blob = bincode::encode_to_vec(&flipped, WIRE_CONFIG).unwrap(); + b.place_raw(&w, "x", &flipped_blob); + + assert!(matches!( + b.store.get_secret(&w, "x", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + let got = b.store.get_secret(&w, "x", None).unwrap().unwrap(); + assert_ne!( + got.expose_secret(), + b"real-seed", + "the real seed must never surface from a flipped scheme byte" + ); + } + + #[test] + fn l1_scheme_flip_file() { + run_scheme_flip(&file_backend()); + } + + #[test] + fn l1_scheme_flip_os() { + run_scheme_flip(&os_backend()); + } + + // ===== Add / change / remove password + arm matrix ===== + // + // These exercise the PUBLIC set_secret/get_secret/reprotect API, so the + // protected writes/reads run the real (default 64 MiB) Argon2 — kept to + // a small number of derivations per test. + + /// The full enrol → change → remove lifecycle, each + /// step verified through the strict read. + fn run_pw_lifecycle(b: &Backend) { + let w = wid(10); + let pw1 = test_password("pw-one"); + let pw2 = test_password("pw-two"); + + // ADD: start unprotected, enrol a password. + b.store + .set(&w, "seed", &SecretBytes::from_slice(b"SEED")) + .unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"SEED" + ); + b.store.reprotect(&w, "seed", None, Some(&pw1)).unwrap(); + assert!( + matches!( + b.store.get(&w, "seed").unwrap_err(), + SecretStoreError::NeedsPassword + ), + "[{}] after add, None read needs a password", + b.name + ); + assert_eq!( + b.store + .get_secret(&w, "seed", Some(&pw1)) + .unwrap() + .unwrap() + .expose_secret(), + b"SEED" + ); + + // CHANGE: rotate to a new password (unwrap-old → rewrap-new). + b.store + .reprotect(&w, "seed", Some(&pw1), Some(&pw2)) + .unwrap(); + assert_eq!( + b.store + .get_secret(&w, "seed", Some(&pw2)) + .unwrap() + .unwrap() + .expose_secret(), + b"SEED" + ); + assert!( + matches!( + b.store.get_secret(&w, "seed", Some(&pw1)).unwrap_err(), + SecretStoreError::WrongPassword + ), + "[{}] old password no longer unlocks after change", + b.name + ); + + // REMOVE: back to unprotected. + b.store.reprotect(&w, "seed", Some(&pw2), None).unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"SEED" + ); + assert!( + matches!( + b.store.get_secret(&w, "seed", Some(&pw2)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + ), + "[{}] after remove, a password read fails closed until the consumer updates its DB", + b.name + ); + } + + #[test] + fn pw_lifecycle_file() { + run_pw_lifecycle(&file_backend()); + } + + #[test] + fn pw_lifecycle_os() { + run_pw_lifecycle(&os_backend()); + } + + /// Losing the object password bricks the object — no recovery + /// path exists, every read fails closed. + fn run_pw_no_recovery(b: &Backend) { + let w = wid(11); + let pw = SecretString::new("the-only-pw"); + b.store + .set_secret(&w, "seed", &SecretBytes::from_slice(b"SEED"), Some(&pw)) + .unwrap(); + assert!(matches!( + b.store + .get_secret(&w, "seed", Some(&test_password("guess"))) + .unwrap_err(), + SecretStoreError::WrongPassword + )); + assert!(matches!( + b.store.get(&w, "seed").unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn pw_no_recovery_file() { + run_pw_no_recovery(&file_backend()); + } + + #[test] + fn pw_no_recovery_os() { + run_pw_no_recovery(&os_backend()); + } + + /// `set`/`get` are additive `..,None` wrappers — `set` + /// writes a scheme-0 envelope, `get` reads it byte-exact, and a + /// password-supplied read of that unprotected object fails closed. + fn run_set_get_wrappers(b: &Backend) { + let w = wid(12); + b.store + .set(&w, "seed", &SecretBytes::from_slice(b"plain")) + .unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"plain" + ); + assert!(matches!( + b.store + .get_secret(&w, "seed", Some(&test_password("pw"))) + .unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + } + + #[test] + fn set_get_wrappers_file() { + run_set_get_wrappers(&file_backend()); + } + + #[test] + fn set_get_wrappers_os() { + run_set_get_wrappers(&os_backend()); + } + + /// The Os arm has no passphrase concept; the Tier-1 blank + /// guard never fires and the round-trip is byte-exact. + #[test] + fn os_arm_roundtrip_no_blank_guard() { + let b = os_backend(); + let w = wid(13); + b.store + .set(&w, "seed", &SecretBytes::from_slice(b"abc")) + .unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"abc" + ); + b.store.delete(&w, "seed").unwrap(); + assert!(b.store.get(&w, "seed").unwrap().is_none()); + } + + /// [File]: a crash (disk-write failure) between the unwrap + /// and the overwrite-commit leaves the OLD protected value intact and + /// readable — no half-rotated / unprotected state. + #[cfg(unix)] + #[test] + fn pw_change_crash_safety_leaves_old_intact_file() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let s = file_store(dir.path()); + let w = wid(14); + let old = test_password("old-pw"); + let new = test_password("new-pw"); + + s.set_secret(&w, "seed", &SecretBytes::from_slice(b"REAL"), Some(&old)) + .unwrap(); + + // Make the vault's parent read-only so the atomic temp-write fails + // mid-change (mirrors rekey_does_not_corrupt_on_disk_temp_failure). + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); + let err = s.reprotect(&w, "seed", Some(&old), Some(&new)).unwrap_err(); + assert!(matches!(err, SecretStoreError::Io(_)), "got {err:?}"); + + // Restore write so the resident store can sync/clean up at drop. + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + // The OLD value is still readable under the OLD password; the new + // password does not unlock it (no half-rotation). + assert_eq!( + s.get_secret(&w, "seed", Some(&old)) + .unwrap() + .unwrap() + .expose_secret(), + b"REAL" + ); + assert!(matches!( + s.get_secret(&w, "seed", Some(&new)).unwrap_err(), + SecretStoreError::WrongPassword + )); + } + + /// [Os]: a backend failure during the rewrite's write (after the read + /// succeeds) leaves the OLD value intact — no half-rotation. The mock's + /// one-shot error injection fails the next write, simulating a crash + /// mid-rewrite. `reprotect` is read-then-`set_secret`, split here so the + /// error lands on the write. + #[test] + fn os_rewrite_mid_write_failure_leaves_old_intact() { + let mock = mock::Store::new().unwrap(); + let store = SecretStore::Os(mock.clone()); + let w = wid(15); + let old = test_password("old-pw"); + let new = test_password("new-pw"); + store + .set_secret(&w, "seed", &SecretBytes::from_slice(b"REAL"), Some(&old)) + .unwrap(); + + // Read succeeds (the rewrite's first step) … + let secret = store.get_secret(&w, "seed", Some(&old)).unwrap().unwrap(); + // … then inject a one-shot backend error so the write fails. + let service = format!("{SERVICE_PREFIX}{}", w.to_hex()); + let entry = mock.build(&service, "seed", None).unwrap(); + let cred: &mock::Cred = entry.as_any().downcast_ref().unwrap(); + cred.set_error(KeyringError::PlatformFailure(Box::new( + std::io::Error::other("simulated backend write failure"), + ))); + let err = store + .set_secret(&w, "seed", &secret, Some(&new)) + .unwrap_err(); + assert!( + matches!(err, SecretStoreError::OsKeyring { .. }), + "got {err:?}" + ); + + // The OLD value is still readable; nothing rotated to `new`. + assert_eq!( + store + .get_secret(&w, "seed", Some(&old)) + .unwrap() + .unwrap() + .expose_secret(), + b"REAL" + ); + assert!(matches!( + store.get_secret(&w, "seed", Some(&new)).unwrap_err(), + SecretStoreError::WrongPassword + )); + } + + // ===== Mock store: floor-params KDF (#4111) ===== + // + // No test asserts the runtime `debug_assertions` guard: under `cargo + // test` the guard's condition is true by construction (both `cfg!(test)` + // and, in the dev profile, `cfg!(debug_assertions)`), so a + // `#[should_panic]` test could never reach the panic — it would only + // assert that a branch we cannot enter was not entered. The gate is + // proven by the deterministic params assertions below plus the + // compile-time `cfg(any(test, feature = "test-util"))`. + + fn mock_store(dir: &std::path::Path) -> SecretStore { + SecretStore::file_mock(secure_vault_path(dir), SecretString::new("pw-correct")).unwrap() + } + + /// The KDF params a stored Tier-2 envelope actually encodes, read back + /// through the store's own raw seam. `None` for an unprotected blob. + fn stored_tier2_params(s: &SecretStore, w: &WalletId, label: &str) -> Option { + use crate::secrets::wire::config::WIRE_CONFIG; + use crate::secrets::wire::envelope::{Envelope, Payload}; + + let blob = s.get_raw(w, label).unwrap().expect("entry present"); + let (env, _): (Envelope, usize) = + bincode::decode_from_slice(blob.expose_secret(), WIRE_CONFIG).unwrap(); + match env.payload { + Payload::Password { kdf, .. } => Some(KdfParams::try_from(kdf).unwrap()), + Payload::Unprotected(_) => None, + } + } + + /// The vault header a `File` store wrote to disk. + fn vault_kdf_on_disk(s: &SecretStore) -> KdfParams { + let SecretStore::File(fs) = s else { + unreachable!("file store") + }; + fs.test_read_vault_from_disk().unwrap().expect("vault").kdf + } + + /// Non-vacuity anchor for every floor-vs-target assertion below: the two + /// params are genuinely different, and the floor is legal (it is the + /// weakest config `enforce_bounds` accepts, so the mock cannot be + /// weakened further and still open). + #[test] + fn floor_target_differs_from_default_and_is_legal() { + let floor = KdfParams::floor_target(); + let target = KdfParams::default_target(); + assert_ne!(floor, target); + assert!(floor.m_kib < target.m_kib && floor.t < target.t); + assert!(floor.enforce_bounds().is_ok()); + // One notch below the floor on either axis is refused, so `floor` is + // the true minimum — not merely "some smaller value". + assert!(KdfParams { + m_kib: floor.m_kib - 1, + ..floor + } + .enforce_bounds() + .is_err()); + assert!(KdfParams { + t: floor.t - 1, + ..floor + } + .enforce_bounds() + .is_err()); + } + + /// The mock's on-disk vault header encodes the FLOOR — so the unlock + /// derivation on every reopen is the cheap one. + #[test] + fn mock_store_vault_header_uses_floor_params() { + let dir = tempfile::tempdir().unwrap(); + let s = mock_store(dir.path()); + assert_eq!(vault_kdf_on_disk(&s), KdfParams::floor_target()); + } + + /// The control: the ordinary constructor still ships the 64 MiB target, + /// so the assertion above is a real difference and not a floor that + /// leaked into production. + #[test] + fn default_store_vault_header_uses_default_target() { + let dir = tempfile::tempdir().unwrap(); + let s = file_store(dir.path()); + assert_eq!(vault_kdf_on_disk(&s), KdfParams::default_target()); + } + + /// A password-protected secret written through the mock's PUBLIC + /// `set_secret` encodes the FLOOR in its Tier-2 envelope — the mock flag + /// reaches the per-secret wrap, not just the vault header. + #[test] + fn mock_store_tier2_envelope_uses_floor_params() { + let dir = tempfile::tempdir().unwrap(); + let s = mock_store(dir.path()); + let w = wid(20); + let pw = SecretString::new("object-pw"); + + s.set_secret(&w, "seed", &SecretBytes::from_slice(b"SEED"), Some(&pw)) + .unwrap(); + assert_eq!( + stored_tier2_params(&s, &w, "seed"), + Some(KdfParams::floor_target()) + ); + + // `reprotect` rewraps through the same seam, so it floors too. + let pw2 = SecretString::new("object-pw-2"); + s.reprotect(&w, "seed", Some(&pw), Some(&pw2)).unwrap(); + assert_eq!( + stored_tier2_params(&s, &w, "seed"), + Some(KdfParams::floor_target()) + ); + } + + /// End-to-end through the mock: the full public surface behaves exactly + /// as on a real store — the cheap KDF changes cost, never semantics. + #[test] + fn mock_store_roundtrips_the_public_surface() { + let dir = tempfile::tempdir().unwrap(); + let s = mock_store(dir.path()); + let w = wid(21); + let pw = SecretString::new("object-pw"); + let pw2 = SecretString::new("object-pw-2"); + + // Unprotected set/get. + s.set(&w, "plain", &SecretBytes::from_slice(b"PLAIN")) + .unwrap(); + assert_eq!( + s.get(&w, "plain").unwrap().unwrap().expose_secret(), + b"PLAIN" + ); + + // Protected set/get, and the strict read still fails closed. + s.set_secret(&w, "seed", &SecretBytes::from_slice(b"SEED"), Some(&pw)) + .unwrap(); + assert_eq!( + s.get_secret(&w, "seed", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"SEED" + ); + assert!(matches!( + s.get(&w, "seed").unwrap_err(), + SecretStoreError::NeedsPassword + )); + assert!(matches!( + s.get_secret(&w, "plain", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + + // reprotect: change, then remove. + s.reprotect(&w, "seed", Some(&pw), Some(&pw2)).unwrap(); + assert_eq!( + s.get_secret(&w, "seed", Some(&pw2)) + .unwrap() + .unwrap() + .expose_secret(), + b"SEED" + ); + assert!(matches!( + s.get_secret(&w, "seed", Some(&pw)).unwrap_err(), + SecretStoreError::WrongPassword + )); + s.reprotect(&w, "seed", Some(&pw2), None).unwrap(); + assert_eq!(s.get(&w, "seed").unwrap().unwrap().expose_secret(), b"SEED"); + + assert!(s.delete(&w, "seed").unwrap()); + assert!(s.get(&w, "seed").unwrap().is_none()); + } + + /// The vault the mock writes is a real one: the passphrase is still + /// verified against the header token on reopen (floor params do not + /// bypass the AAD-bound verify-token), and a wrong one fails closed. + #[test] + fn mock_store_still_verifies_the_passphrase_on_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = secure_vault_path(dir.path()); + { + let s = mock_store(dir.path()); + s.set(&wid(22), "seed", &SecretBytes::from_slice(b"SEED")) + .unwrap(); + } // drop releases the vault lock + + let err = SecretStore::file_mock(&path, SecretString::new("pw-wrong")) + .expect_err("wrong passphrase must fail open"); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "got {err:?}" + ); + + let s = SecretStore::file_mock(&path, SecretString::new("pw-correct")).unwrap(); + assert_eq!( + s.get(&wid(22), "seed").unwrap().unwrap().expose_secret(), + b"SEED" + ); + } + + /// [Os]: the read-size guard rejects an oversized backend blob (a + /// malicious keychain returning more than a legitimate envelope ever + /// could) BEFORE it reaches the envelope parse/derive path. The bound is + /// `MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD`; both the `get_secret` and + /// legacy `get` read paths enforce it. + #[test] + fn os_read_rejects_oversized_blob() { + let b = os_backend(); + let w = wid(16); + let cap = MAX_SECRET_LEN + envelope::MAX_ENVELOPE_OVERHEAD; + // Attacker writes a blob one byte over the cap straight to the slot. + b.place_raw(&w, "seed", &vec![0u8; cap + 1]); + let err = b.store.get_secret(&w, "seed", None).unwrap_err(); + assert!( + matches!(err, SecretStoreError::SecretTooLarge { found, max } if found == cap + 1 && max == cap), + "get_secret got {err:?}" + ); + // The legacy `get` path is bounded too. + assert!(matches!( + b.store.get(&w, "seed").unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } if found == cap + 1 && max == cap + )); + } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs new file mode 100644 index 00000000000..f6b750eef76 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs @@ -0,0 +1,252 @@ +//! Bincode-encoded AAD structs for the three contexts that authenticate +//! ciphertexts under `secrets/`: Tier-2 scheme-1 envelopes, vault entry +//! bodies, and the vault passphrase-verify token. +//! +//! Each struct is `Encode`-only — AAD is producer-side; the decoder +//! re-builds it from the surrounding context and bincode-encodes again +//! against [`WIRE_CONFIG`]. Pair-wise byte disjointness is guaranteed by +//! the three domain constants declared in [`super::config`] and pinned +//! empirically by the tests `tier2_and_entry_aad_byte_disjoint`, +//! `tier2_and_verify_aad_byte_disjoint`, and +//! `entry_and_verify_aad_byte_disjoint`. + +use crate::secrets::file::crypto::SALT_LEN; +use crate::secrets::wire::kdf::KdfParamsEncoded; + +/// AAD bound into every scheme-1 (password-protected) Tier-2 envelope. +/// Binds object identity (`wallet_id` + `label`) + header +/// (`envelope_version`, `scheme_discriminant`, `kdf`, `salt`) so any +/// in-place edit of those fields fails the AEAD tag. +/// +/// `scheme_discriminant` is explicit (not inferred from a Rust enum +/// variant tag) so the AAD shape is stable under a future `Payload` +/// re-ordering. +#[derive(bincode::Encode)] +pub(crate) struct Tier2Aad<'a> { + /// Domain tag — `TIER2_DOMAIN_V2`. Length-prefixed by bincode and + /// byte-disjoint from `ENTRY_DOMAIN_V2` / `VERIFY_DOMAIN_V2` by + /// content past the common prefix; pinned by the disjointness tests + /// in [`super::aad::tests`]. + pub domain: &'static [u8], + /// Envelope wire version (`ENVELOPE_VERSION`). + pub envelope_version: u32, + /// `0 = Unprotected`, `1 = Password`. Authenticates the scheme byte + /// independently of the enum's bincode-derived tag. + pub scheme_discriminant: u8, + /// The exact bytes encoded into the envelope's `Payload::Password` + /// body — AAD == body, so a wire-edited KDF header fails the tag. + pub kdf: KdfParamsEncoded, + /// Per-wrap CSPRNG salt. + pub salt: [u8; SALT_LEN], + /// 32-byte wallet correlation id (public, not secret). + pub wallet_id: [u8; 32], + /// Caller-allowlisted slot label. + pub label: &'a str, +} + +/// AAD bound into every vault entry's AEAD seal. Replaces the +/// hand-rolled `format::aad()` byte concatenation; binds slot identity +/// (`wallet_id` + `label`) at a stable `format_version`. A relocated +/// or version-rolled-back blob fails the tag. +#[derive(bincode::Encode)] +pub(crate) struct EntryAad<'a> { + /// Domain tag — `ENTRY_DOMAIN_V2`. + pub domain: &'static [u8], + /// Vault `FORMAT_VERSION` (the compiled-in dispatch version, + /// never the parsed JSON version). + pub format_version: u32, + /// 32-byte wallet correlation id. + pub wallet_id: [u8; 32], + /// Caller-allowlisted slot label. + pub label: &'a str, +} + +/// AAD bound into the vault passphrase-verify token's AEAD seal. +/// Binds salt + KDF header so a flipped salt or KDF-param shift fails +/// the token tag (surfaces as `WrongPassphrase` — a tampered header +/// also yields a different derived key). +#[derive(bincode::Encode)] +pub(crate) struct VerifyAad { + /// Domain tag — `VERIFY_DOMAIN_V2`. + pub domain: &'static [u8], + /// Vault `FORMAT_VERSION`. + pub format_version: u32, + /// Vault-wide CSPRNG salt. + pub salt: [u8; SALT_LEN], + /// Vault-wide KDF parameters (the same wire image used by every + /// scheme-1 Tier-2 envelope). + pub kdf: KdfParamsEncoded, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secrets::file::crypto::KdfParams; + use crate::secrets::wire::config::{ + ENTRY_DOMAIN_V2, TIER2_DOMAIN_V2, VERIFY_DOMAIN_V2, WIRE_CONFIG, + }; + + fn floor_kdf() -> KdfParamsEncoded { + KdfParamsEncoded::from(KdfParams::default_target()) + } + + fn tier2_with_domain(domain: &'static [u8]) -> Vec { + let aad = Tier2Aad { + domain, + envelope_version: 1, + scheme_discriminant: 1, + kdf: floor_kdf(), + salt: [0x77u8; SALT_LEN], + wallet_id: [0x11u8; 32], + label: "seed", + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn tier2_with_version(envelope_version: u32) -> Vec { + let aad = Tier2Aad { + domain: TIER2_DOMAIN_V2, + envelope_version, + scheme_discriminant: 1, + kdf: floor_kdf(), + salt: [0x77u8; SALT_LEN], + wallet_id: [0x11u8; 32], + label: "seed", + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn tier2_with_scheme(scheme_discriminant: u8) -> Vec { + let aad = Tier2Aad { + domain: TIER2_DOMAIN_V2, + envelope_version: 1, + scheme_discriminant, + kdf: floor_kdf(), + salt: [0x77u8; SALT_LEN], + wallet_id: [0x11u8; 32], + label: "seed", + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn entry(format_version: u32, wallet_id: [u8; 32], label: &str) -> Vec { + let aad = EntryAad { + domain: ENTRY_DOMAIN_V2, + format_version, + wallet_id, + label, + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn verify(salt: [u8; SALT_LEN], kdf: KdfParamsEncoded) -> Vec { + let aad = VerifyAad { + domain: VERIFY_DOMAIN_V2, + format_version: 1, + salt, + kdf, + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + /// Two byte strings where neither is a prefix of the other. + fn assert_prefix_disjoint(a: &[u8], b: &[u8]) { + assert!( + !a.starts_with(b) && !b.starts_with(a), + "prefix containment: a.len={} b.len={}", + a.len(), + b.len() + ); + } + + /// TC-014 — Tier2Aad.domain is bincode-encoded. + #[test] + fn tier2_aad_domain_field_binds_bytes() { + let a = tier2_with_domain(TIER2_DOMAIN_V2); + let b = tier2_with_domain(b"PWSEV-TIER2-AAD-vX"); + assert_ne!(a, b); + assert_prefix_disjoint(&a, &b); + } + + /// TC-015 — Tier2Aad.envelope_version is bincode-encoded. + #[test] + fn tier2_aad_envelope_version_field_binds_bytes() { + assert_ne!(tier2_with_version(1), tier2_with_version(2)); + } + + /// TC-016 — Tier2Aad.scheme_discriminant is bincode-encoded and + /// explicit (not inferred from a Rust enum tag). + #[test] + fn tier2_aad_scheme_discriminant_field_binds_bytes() { + assert_ne!(tier2_with_scheme(0), tier2_with_scheme(1)); + } + + /// TC-025 — Tier2Aad and EntryAad are byte-disjoint at the prefix. + #[test] + fn tier2_and_entry_aad_byte_disjoint() { + let t = tier2_with_domain(TIER2_DOMAIN_V2); + let e = entry(1, [0x11u8; 32], "seed"); + assert_prefix_disjoint(&t, &e); + } + + /// TC-026 — Tier2Aad and VerifyAad are byte-disjoint at the prefix. + #[test] + fn tier2_and_verify_aad_byte_disjoint() { + let t = tier2_with_domain(TIER2_DOMAIN_V2); + let v = verify([0x77u8; SALT_LEN], floor_kdf()); + assert_prefix_disjoint(&t, &v); + } + + /// TC-027 — EntryAad and VerifyAad are byte-disjoint at the prefix. + /// Now backed by an explicit domain constant on top of the existing + /// VERIFY_LABEL leading-NUL trick at the `format.rs` call site. + #[test] + fn entry_and_verify_aad_byte_disjoint() { + let e = entry(1, [0u8; 32], "\0verify"); + let v = verify([0x77u8; SALT_LEN], floor_kdf()); + assert_prefix_disjoint(&e, &v); + } + + /// TC-037 — EntryAad binds (format_version, wallet_id, label) and + /// the label encoding carries its length prefix (`"a"+"b"` vs + /// `"ab"` are distinct). + #[test] + fn entry_aad_binds_format_version_wallet_id_and_label() { + let base = entry(1, [1u8; 32], "a"); + assert_ne!(base, entry(2, [1u8; 32], "a")); + assert_ne!(base, entry(1, [2u8; 32], "a")); + assert_ne!(base, entry(1, [1u8; 32], "b")); + // Length-prefix sanity: "ab" must not equal the concatenation of + // the encoding of "a" with the literal byte `b`. + let ab = entry(1, [1u8; 32], "ab"); + let mut a_plus_b = base.clone(); + a_plus_b.extend_from_slice(b"b"); + assert_ne!(ab, a_plus_b); + } + + /// TC-038 — VerifyAad binds salt + KDF; identical inputs produce + /// identical bytes (determinism). + #[test] + fn verify_aad_binds_salt_and_kdf_params() { + let salt = [7u8; SALT_LEN]; + let kdf = floor_kdf(); + let base = verify(salt, kdf); + let mut salt2 = salt; + salt2[0] ^= 0x01; + assert_ne!(base, verify(salt2, kdf)); + + let kdf_mkib = KdfParamsEncoded { + m_kib: kdf.m_kib / 2, + ..kdf + }; + assert_ne!(base, verify(salt, kdf_mkib)); + let kdf_t = KdfParamsEncoded { + t: kdf.t - 1, + ..kdf + }; + assert_ne!(base, verify(salt, kdf_t)); + + // Determinism: identical inputs ⇒ identical bytes. + assert_eq!(base, verify(salt, kdf)); + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs new file mode 100644 index 00000000000..b6c6031a63f --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs @@ -0,0 +1,43 @@ +//! Single bincode configuration + domain / version constants every +//! encoder in `secrets/wire/` uses. +//! +//! `WIRE_CONFIG` matches the platform-wide +//! `bincode::config::standard().with_big_endian().with_no_limit()` +//! (`rs-platform-serialization`) — big-endian for human-readable hex +//! dumps, varint integer encoding, no decode limit. +//! +//! Changing this constant invalidates every stored Tier-2 blob; the +//! golden-vector tests in [`super::envelope::tests`] catch any drift. + +use bincode::config::{BigEndian, Configuration, NoLimit, Varint}; + +/// The one bincode config used to encode every wire byte under +/// `secrets/wire/` (envelope payload + the three AAD structs). +pub(crate) const WIRE_CONFIG: Configuration = + bincode::config::standard() + .with_big_endian() + .with_no_limit(); + +/// Tier-2 envelope wire version — bumped only on a breaking layout +/// change, independent of the vault `FORMAT_VERSION`. Bound into every +/// scheme-1 envelope's AAD so a forged version byte fails the tag. +pub(crate) const ENVELOPE_VERSION: u32 = 1; + +/// Domain-separation tag leading the Tier-2 scheme-1 AAD. `-v2` marks the +/// wire-format break from the pre-bincode hand-rolled `PWSEV-TIER2-AAD-v1`. +pub(crate) const TIER2_DOMAIN_V2: &[u8] = b"PWSEV-TIER2-AAD-v2"; + +/// Domain-separation tag leading every vault `EntryAad`. Pre-bincode +/// `aad()` had no domain tag; bound here for symmetry + cross-context +/// disjointness with [`TIER2_DOMAIN_V2`] and [`VERIFY_DOMAIN_V2`]. +pub(crate) const ENTRY_DOMAIN_V2: &[u8] = b"PWSV-ENTRY-AAD-v2"; + +/// Domain-separation tag leading every vault `VerifyAad`. Disjoint +/// from [`TIER2_DOMAIN_V2`] and [`ENTRY_DOMAIN_V2`] by **content past +/// the common prefix** (the three tags are NOT length-distinct — +/// TIER2 and VERIFY are both 18 bytes; ENTRY is 17). Pair-wise +/// byte-disjointness is pinned by the tests +/// `tier2_and_verify_aad_byte_disjoint`, +/// `tier2_and_entry_aad_byte_disjoint`, and +/// `entry_and_verify_aad_byte_disjoint`. +pub(crate) const VERIFY_DOMAIN_V2: &[u8] = b"PWSV-VERIFY-AAD-v2"; diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs new file mode 100644 index 00000000000..3674bbaad24 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs @@ -0,0 +1,1300 @@ +//! Tier-2 envelope wire format — bincode-encoded `Envelope` / `Payload` +//! plus the [`wrap_with_params`] / [`unwrap`] API. +//! +//! Every byte that crosses the AEAD seam is produced by +//! `bincode::encode_to_vec` against [`WIRE_CONFIG`], so a future config +//! drift surfaces in the golden-vector tests, not in silently corrupted +//! blobs. Decoding goes through [`DECODE_CONFIG`] — the same +//! configuration with a byte limit, so a hostile blob declaring a +//! multi-GiB length prefix is rejected before any allocation. + +use bincode::config::{BigEndian, Configuration, Limit, Varint}; +use zeroize::Zeroize; + +use crate::secrets::error::SecretStoreError; +use crate::secrets::file::crypto::{self, KdfParams, NONCE_LEN, SALT_LEN}; +use crate::secrets::secret::{SecretBytes, SecretString, MAX_PASSPHRASE_LEN}; +use crate::secrets::validate::WalletId; +use crate::secrets::wire::aad::Tier2Aad; +use crate::secrets::wire::config::{ENVELOPE_VERSION, TIER2_DOMAIN_V2, WIRE_CONFIG}; +use crate::secrets::wire::kdf::KdfParamsEncoded; +use crate::secrets::MAX_SECRET_LEN; + +/// On-disk Tier-2 wire envelope. The whole struct is bincode-encoded +/// in one call; a wire-edited `version` is gated to +/// `SecretStoreError::UnsupportedEnvelopeVersion` before dispatch. +#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq)] +pub(crate) struct Envelope { + /// Envelope wire version (`ENVELOPE_VERSION`). + pub version: u32, + /// Tagged payload selecting unprotected vs password-protected. + pub payload: Payload, +} + +/// Tagged payload: scheme-0 ships the plaintext as-is (the backend's +/// own at-rest crypto is the only defence); scheme-1 ships the AEAD +/// triple under an object-password-derived key. +#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq)] +pub(crate) enum Payload { + /// Scheme 0 — unprotected passthrough; the bytes are the secret. + Unprotected(Vec), + /// Scheme 1 — sealed under an Argon2id-derived key with + /// XChaCha20-Poly1305. The AAD bound at seal time is + /// [`crate::secrets::wire::aad::Tier2Aad`]. + Password { + /// Argon2 parameters used to derive the key. + kdf: KdfParamsEncoded, + /// Per-wrap CSPRNG salt fed into Argon2. + salt: [u8; SALT_LEN], + /// Per-wrap CSPRNG nonce fed into XChaCha20-Poly1305. + nonce: [u8; NONCE_LEN], + /// Ciphertext + 16-byte Poly1305 tag. + ciphertext: Vec, + }, +} + +impl Payload { + /// Zeroize the heap buffer this payload carries. + /// + /// `bincode` decodes `Unprotected` into an ordinary unguarded `Vec` — + /// the raw secret, outside every control `secrets::guarded` provides. + /// The success path launders it through `SecretBytes::new`; every early + /// return in [`unwrap`] wipes instead — through this method before the + /// payload is destructured, directly on the moved buffer after — or the + /// secret is left on the heap for a later re-read, a core dump, or swap. + fn wipe(&mut self) { + // In place rather than `Vec::zeroize`, which clears to length 0 and + // would hide the wipe from `payload_wipe_clears_both_variants`. + match self { + Payload::Unprotected(plaintext) => plaintext.as_mut_slice().zeroize(), + Payload::Password { ciphertext, .. } => ciphertext.as_mut_slice().zeroize(), + } + } +} + +/// Upper bound on the bincode-encoded envelope overhead over its +/// plaintext (header + KDF + salt + nonce + AEAD tag + bincode framing). +/// Pinned by a runtime cross-check in `tests::max_envelope_overhead_matches_runtime` +/// so any bincode-config drift surfaces immediately. The smallest +/// scheme-1 envelope (empty plaintext sealed → 16-byte tag) measures +/// 81 bytes; rounded up to the next 16-byte boundary that satisfies a +/// 16-byte safety margin (81 + 16 = 97 → 112) for headroom against a +/// future header field. +pub(crate) const MAX_ENVELOPE_OVERHEAD: usize = 112; + +/// Plaintext cap at the envelope boundary: `MAX_SECRET_LEN − +/// MAX_ENVELOPE_OVERHEAD`. Capping the plaintext (uniformly for both +/// schemes) keeps the user-visible limit stable AND guarantees the +/// enveloped bytes always fit the backend vault's own `MAX_SECRET_LEN` +/// `put_bytes` cap. +pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; + +/// Decode-side budget: caps the bytes the bincode decoder will consume +/// from a single envelope. Equal to the on-disk cap. +const DECODE_BUDGET: usize = MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD; + +/// Bincode decode config — derived from [`WIRE_CONFIG`] but with a +/// [`DECODE_BUDGET`] byte limit applied. +/// +/// **Asymmetric on purpose, security-positive deviation from +/// design-brief NF2** (which locks the wire config to +/// `with_no_limit()`). The deviation exists for hostile-decode +/// hardening: an attacker-controlled length prefix in the blob would +/// otherwise drive `Vec::with_capacity` to a multi-GiB allocation +/// before any tag check. With `Limit`, bincode refuses the +/// allocation up front and the unwrap fails closed as `Corruption`. +/// +/// The encoder retains [`WIRE_CONFIG`] (no limit) because AAD and +/// envelope encoding are producer-only — every input is library-owned +/// and bounded by `MAX_PLAINTEXT_LEN`, so a limit there has no +/// security benefit and would be a foot-gun against legitimate +/// at-cap secrets. +const DECODE_CONFIG: Configuration> = + WIRE_CONFIG.with_limit::(); + +/// Wrap `plaintext` for `(wallet_id, label)` under Argon2 `params`. +/// +/// `None` → an unprotected (scheme-0) envelope, and `params` is unused; +/// `Some(pw)` → a scheme-1 envelope sealed under `pw`. A sub-floor password +/// is rejected at enrol (`SecretStoreError::BlankPassphrase`). +/// +/// Callers pass [`KdfParams::default_target`]; a mock store +/// ([`SecretStore::file_mock`]) passes the floor instead (#4111). +/// +/// Returns the envelope inside a zeroizing [`SecretBytes`]. +/// +/// [`SecretStore::file_mock`]: crate::secrets::SecretStore::file_mock +pub(crate) fn wrap_with_params( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + plaintext: &[u8], + params: KdfParams, +) -> Result { + // Cap the PLAINTEXT (before overhead) uniformly for both schemes so + // the enveloped bytes always fit the backend cap. + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + + let Some(pw) = password else { + // The scheme-0 plaintext copy rides the envelope in the clear. Encode, + // then wipe that copy before it drops — the returned SecretBytes is the + // only retained copy and zeroizes itself. + let mut envelope = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(plaintext.to_vec()), + }; + let encoded = encode_envelope(&envelope); + if let Payload::Unprotected(ref mut bytes) = envelope.payload { + bytes.zeroize(); + } + return Ok(SecretBytes::new(encoded)); + }; + + // Reject an out-of-range object password before any salt or derivation. + if pw.is_below_minimum_passphrase_len() { + return Err(SecretStoreError::BlankPassphrase); + } + if pw.exceeds_maximum_passphrase_len() { + return Err(SecretStoreError::PassphraseTooLong { + found: pw.len(), + max: MAX_PASSPHRASE_LEN, + }); + } + + let mut salt = [0u8; SALT_LEN]; + crypto::random_bytes(&mut salt)?; + let kdf = KdfParamsEncoded::from(params); + let aad = encode_tier2_aad(wallet_id, label, kdf, &salt); + // Scoped so the derived key's guarded page is released before the + // envelope buffer is allocated; the two are never both needed. + let (nonce, ciphertext) = { + let key = crypto::derive_key(pw, &salt, params)?; + crypto::seal(&key, &aad, plaintext)? + }; + + let envelope = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Password { + kdf, + salt, + nonce, + ciphertext, + }, + }; + Ok(SecretBytes::new(encode_envelope(&envelope))) +} + +/// Bincode-encode the scheme-1 AAD against [`WIRE_CONFIG`]. Shared by +/// [`wrap_with_params`] and [`unwrap_password_payload`] so the encode +/// and decode AADs cannot drift apart. +pub(crate) fn encode_tier2_aad( + wallet_id: &WalletId, + label: &str, + kdf: KdfParamsEncoded, + salt: &[u8; SALT_LEN], +) -> Vec { + let aad = Tier2Aad { + domain: TIER2_DOMAIN_V2, + envelope_version: ENVELOPE_VERSION, + scheme_discriminant: 1, + kdf, + salt: *salt, + wallet_id: *wallet_id.as_bytes(), + label, + }; + // AAD encode is infallible — every field is owned/borrowed bincode- + // Encode-able. A failure would be a logic bug. + bincode::encode_to_vec(aad, WIRE_CONFIG).expect("Tier2Aad encode is infallible") +} + +/// Bincode-encode the whole envelope. Wrapping in `SecretBytes::new` +/// keeps the (possibly plaintext-bearing) scheme-0 buffer zeroizing. +fn encode_envelope(envelope: &Envelope) -> Vec { + bincode::encode_to_vec(envelope, WIRE_CONFIG).expect("Envelope encode is infallible") +} + +/// Unwrap `blob` for `(wallet_id, label)`, applying the strict +/// fail-closed read. +/// +/// `password` carries the caller's protection assertion — never the +/// blob's scheme byte. Decode errors (truncated, garbage bytes, unknown +/// enum tag) collapse to `Corruption`; an envelope version this build +/// does not recognise yields `UnsupportedEnvelopeVersion` ahead of +/// dispatch. +/// +/// | `password` | `payload` | result | +/// |---|---|---| +/// | `Some(pw)` | `Password { .. }` | the secret, or `WrongPassword` on tag fail | +/// | `Some(pw)` | `Unprotected(_)` | `ExpectedProtectedButUnsealed` (strip/downgrade) | +/// | `None` | `Password { .. }` | `NeedsPassword` (never ciphertext) | +/// | `None` | `Unprotected(pt)` | the secret | +pub(crate) fn unwrap( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + blob: &[u8], +) -> Result { + let (mut envelope, consumed) = bincode::decode_from_slice::(blob, DECODE_CONFIG) + .map_err(|_| SecretStoreError::Corruption)?; + // Trailing bytes after a valid decode are a truncation/extension + // probe — fail closed. Both pre-dispatch refusals wipe first: the + // decoded payload may already hold a scheme-0 plaintext. + if consumed != blob.len() { + envelope.payload.wipe(); + return Err(SecretStoreError::Corruption); + } + + if envelope.version != ENVELOPE_VERSION { + envelope.payload.wipe(); + return Err(SecretStoreError::UnsupportedEnvelopeVersion { + found: envelope.version, + }); + } + + match (envelope.payload, password) { + (Payload::Unprotected(mut plaintext), None) => { + // Enforce the same cap the wrap side applies. DECODE_BUDGET is + // larger than MAX_PLAINTEXT_LEN (by MAX_ENVELOPE_OVERHEAD), so a + // tampered blob can pass the bincode budget check yet exceed the + // application-level plaintext ceiling; reject it here. + if plaintext.len() > MAX_PLAINTEXT_LEN { + let found = plaintext.len(); + plaintext.as_mut_slice().zeroize(); + return Err(SecretStoreError::SecretTooLarge { + found, + max: MAX_PLAINTEXT_LEN, + }); + } + Ok(SecretBytes::new(plaintext)) + } + // Caller asserted protection but blob is unprotected: strip / + // downgrade — fail closed, never return the bytes, and never leave + // them on the heap either. + (Payload::Unprotected(mut plaintext), Some(_)) => { + plaintext.as_mut_slice().zeroize(); + Err(SecretStoreError::ExpectedProtectedButUnsealed) + } + (Payload::Password { .. }, None) => Err(SecretStoreError::NeedsPassword), + ( + Payload::Password { + kdf, + salt, + nonce, + ciphertext, + }, + Some(pw), + ) => unwrap_password_payload(wallet_id, label, pw, kdf, salt, nonce, &ciphertext), + } +} + +/// Decrypt a `Payload::Password` body. The KDF params, salt and nonce +/// come from the (attacker-controllable) envelope; `enforce_bounds` AND +/// the stricter wire-stable per-read ceiling gate the params BEFORE +/// `derive_key` allocates. +fn unwrap_password_payload( + wallet_id: &WalletId, + label: &str, + password: &SecretString, + kdf_encoded: KdfParamsEncoded, + salt: [u8; SALT_LEN], + nonce: [u8; NONCE_LEN], + ciphertext: &[u8], +) -> Result { + // (a0) Mirror wrap's floor on read so a backend-write attacker cannot + // plant a weakly sealed envelope for an accidentally weak caller input. + if password.is_below_minimum_passphrase_len() { + return Err(SecretStoreError::BlankPassphrase); + } + // (a1) Mirror wrap's ceiling too. A re-protect holds the old password, + // the new one and the resident vault passphrase at once, so all three + // must be bounded for the budget at `MAX_SECRET_LEN` to hold. Refusing + // here locks nobody out: wrap applies the same ceiling, so no + // legitimately enrolled entry can need a longer password. + if password.exceeds_maximum_passphrase_len() { + return Err(SecretStoreError::PassphraseTooLong { + found: password.len(), + max: MAX_PASSPHRASE_LEN, + }); + } + // (a) Wider Argon2 floors/ceilings — refuses an inflated header + // before any allocation. + let kdf = KdfParams::try_from(kdf_encoded)?; + // (b) Per-read ceiling tighter than `enforce_bounds`: a header + // declaring more memory OR more time than `ARGON2_READ_MAX_*` is + // refused before `derive_key` allocates, closing the gap between the + // 1 GiB / 16-pass DoS band and the shipped cost. Gated on wire-stable + // constants, NOT `default_target()`: a read ceiling tied to a tunable + // would orphan every enrolled secret the day the shipped default is + // lowered for a low-RAM host, with no recovery path. + kdf.enforce_read_ceiling()?; + // (c) AAD binds identity + header — the same bytes the encoder + // produced, by construction. + let aad = encode_tier2_aad(wallet_id, label, kdf_encoded, &salt); + let key = crypto::derive_key(password, &salt, kdf)?; + match crypto::open(&key, &nonce, &aad, ciphertext) { + Ok(plaintext) => { + // A wrap-produced blob is always within MAX_PLAINTEXT_LEN; a + // plaintext that exceeds the cap after successful AEAD + // authentication must have been produced by a different build or + // is tampered — fail closed. + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + Ok(plaintext) + } + // Tag failure (wrong password, relocated blob, header tamper): + // no plaintext ever materialises (CWE-347). + Err(SecretStoreError::Decrypt) => Err(SecretStoreError::WrongPassword), + Err(e) => Err(e), + } +} + +/// Test-only deterministic encoder: takes pre-supplied `salt` and +/// `nonce` instead of pulling from the CSPRNG, so golden-vector tests +/// produce reproducible bytes. Production callers MUST use +/// [`wrap_with_params`]. +#[cfg(test)] +pub(crate) fn wrap_with_params_for_test( + wallet_id: &WalletId, + label: &str, + pw: &SecretString, + plaintext: &[u8], + params: KdfParams, + salt: [u8; SALT_LEN], + nonce: [u8; NONCE_LEN], +) -> Result { + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + if pw.is_below_minimum_passphrase_len() { + return Err(SecretStoreError::BlankPassphrase); + } + let key = crypto::derive_key(pw, &salt, params)?; + let kdf = KdfParamsEncoded::from(params); + let aad = encode_tier2_aad(wallet_id, label, kdf, &salt); + let (nonce, ciphertext) = crypto::seal_with_nonce(&key, nonce, &aad, plaintext)?; + let envelope = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Password { + kdf, + salt, + nonce, + ciphertext, + }, + }; + Ok(SecretBytes::new(encode_envelope(&envelope))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secrets::file::crypto::{ARGON2_MIN_M_KIB, ARGON2_MIN_T}; + + /// Captured once from the runtime encoder; a subsequent CI failure + /// here means a wire-format drift to investigate, NOT to "fix" by + /// re-generating the constant. + /// + /// Decoding: 0x01 envelope.version=1, 0x00 Payload::Unprotected, + /// 0x05 Vec length=5, "hello". + const SCHEME0_GOLDEN_HEX: &str = "01000568656c6c6f"; + + /// scheme-1 deterministic golden: wid=[0;32], label="seed", + /// pw="pw------", plaintext="hello", floor params, salt=[0x11;32], + /// nonce=[0x22;24]. Bytes: version + Payload::Password tag + + /// kdf(id,m_kib,t,p as varints) + salt[32] + nonce[24] + + /// ciphertext-with-tag length + ciphertext+tag(21B). + const SCHEME1_GOLDEN_HEX: &str = "010101fb4c0002011111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222152b9c8f5632a7bad30ca908db231f1aa2c897982352"; + + fn wid(b: u8) -> WalletId { + WalletId::from([b; 32]) + } + + /// Pad nonblank fixture labels to the production length floor. + fn pw(s: &str) -> SecretString { + if s.trim().is_empty() { + return SecretString::new(s); + } + let mut value = s.to_owned(); + while value.trim().len() < crate::secrets::MIN_PASSPHRASE_LEN { + value.push('-'); + } + SecretString::new(value) + } + + /// TC-033 — blank object password rejected at enrol (wrap-side). + #[test] + fn blank_object_password_rejected_at_wrap() { + for blank in [SecretString::empty(), pw(""), pw(" "), pw("\t\n")] { + let err = wrap_with_params( + &wid(1), + "seed", + Some(&blank), + b"seed", + KdfParams::floor_target(), + ) + .unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "got {err:?}" + ); + } + } + + /// Symmetric guard on the read side: a `Some(blank)` password reaching + /// `unwrap_password_payload` is refused with `BlankPassphrase` BEFORE + /// any KDF or AEAD work — never `WrongPassword`, never `Decrypt`, + /// never plaintext. Pins the contract that closes the asymmetry where + /// a backend-write attacker could plant a scheme-1 envelope sealed + /// under the blank password and have a caller that accidentally + /// forwards `Some(SecretString::empty())` accept attacker-controlled + /// plaintext. + #[test] + fn unwrap_password_payload_rejects_some_blank_password() { + let blob = scheme1_blob(&pw("good")); + let err = unwrap(&wid(1), "seed", Some(&SecretString::empty()), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "blank object password must be refused before KDF/AEAD, got {err:?}" + ); + } + + /// The object-password ceiling is enforced symmetrically, wrap and + /// unwrap. Both sides matter: a re-protect holds the old password, the + /// new one and the resident vault passphrase at once, so all three + /// must be bounded for `MAX_SECRET_LEN`'s budget to hold. Enforcing it + /// on read locks nobody out, because wrap refuses to enrol one. + #[test] + fn object_password_cap_accept_then_reject_on_both_sides() { + let at_cap = SecretString::new("p".repeat(MAX_PASSPHRASE_LEN)); + let over = SecretString::new("p".repeat(MAX_PASSPHRASE_LEN + 1)); + + let blob = wrap_with_params( + &wid(1), + "seed", + Some(&at_cap), + b"seed", + KdfParams::floor_target(), + ) + .expect("a password exactly at the cap must be accepted"); + assert_eq!( + unwrap(&wid(1), "seed", Some(&at_cap), blob.expose_secret()) + .unwrap() + .expose_secret(), + b"seed" + ); + + let wrap_err = wrap_with_params( + &wid(1), + "seed", + Some(&over), + b"seed", + KdfParams::floor_target(), + ) + .unwrap_err(); + assert!( + matches!(wrap_err, SecretStoreError::PassphraseTooLong { found, max } + if found == MAX_PASSPHRASE_LEN + 1 && max == MAX_PASSPHRASE_LEN), + "got {wrap_err:?}" + ); + + // Refused before any KDF or AEAD work, so never as WrongPassword. + let unwrap_err = unwrap(&wid(1), "seed", Some(&over), blob.expose_secret()).unwrap_err(); + assert!( + matches!(unwrap_err, SecretStoreError::PassphraseTooLong { .. }), + "got {unwrap_err:?}" + ); + } + + /// TC-034 — plaintext cap accept at MAX_PLAINTEXT_LEN, reject at + /// +1, for both schemes. + #[test] + fn plaintext_cap_accept_then_reject() { + let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let over = vec![0x5Au8; MAX_PLAINTEXT_LEN + 1]; + + // Scheme 0 — `params` is unused on the unprotected path. + assert!( + wrap_with_params(&wid(1), "seed", None, &at_cap, KdfParams::floor_target()).is_ok() + ); + assert!(matches!( + wrap_with_params(&wid(1), "seed", None, &over, KdfParams::floor_target()).unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + )); + + // Scheme 1 — cap check fires before any derivation. + let p = pw("pw"); + assert!(matches!( + wrap_with_params(&wid(1), "seed", Some(&p), &over, KdfParams::floor_target()).unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + )); + + // Scheme-0 enveloped bytes for an at-cap plaintext fit the backend cap. + let enveloped = + wrap_with_params(&wid(1), "seed", None, &at_cap, KdfParams::floor_target()).unwrap(); + assert!(enveloped.len() <= MAX_SECRET_LEN); + } + + /// TC-035 (size-budget half) — scheme-1 accepts plaintext at the + /// exact MAX_PLAINTEXT_LEN boundary; the enveloped bytes fit the + /// backend cap. The round-trip half is `scheme1_at_cap_round_trips_within_backend_cap`. + #[test] + fn scheme1_at_cap_envelope_fits_backend_cap() { + let p = pw("pw"); + let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let blob = + wrap_with_params(&wid(1), "seed", Some(&p), &pt, KdfParams::floor_target()).unwrap(); + assert!( + blob.len() <= MAX_SECRET_LEN, + "enveloped bytes ({} B) exceed backend cap ({} B)", + blob.len(), + MAX_SECRET_LEN + ); + } + + /// TC-028 — golden hex vector for the scheme-0 wire bytes. Any + /// bincode-config drift (endianness, varint mode, limit) trips this. + #[test] + fn scheme0_golden_vector_matches_const() { + let blob = wrap_with_params( + &WalletId::from([0u8; 32]), + "seed", + None, + b"hello", + KdfParams::floor_target(), + ) + .unwrap(); + let actual = hex::encode(blob.expose_secret()); + assert_eq!(actual, SCHEME0_GOLDEN_HEX); + } + + /// TC-029 — golden hex vector for the scheme-1 wire bytes, produced + /// via the deterministic encoder seam. + #[test] + fn scheme1_golden_vector_matches_const() { + let blob = wrap_with_params_for_test( + &WalletId::from([0u8; 32]), + "seed", + &pw("pw"), + b"hello", + KdfParams::floor_target(), + [0x11u8; SALT_LEN], + [0x22u8; NONCE_LEN], + ) + .unwrap(); + let actual = hex::encode(blob.expose_secret()); + assert_eq!(actual, SCHEME1_GOLDEN_HEX); + } + + /// Minimum overhead within budget AND the budget not absurdly above + /// the actual encoding — bound on both sides so the constant stays + /// honest as the wire shape evolves. + const SAFETY_MARGIN: usize = 16; + + /// TC-030 — `MAX_ENVELOPE_OVERHEAD` cross-checks the runtime + /// bincode encoding of the smallest possible scheme-1 envelope + /// (empty plaintext sealed → ciphertext == 16-byte AEAD tag). + #[test] + fn max_envelope_overhead_matches_runtime() { + let blob = wrap_with_params_for_test( + &WalletId::from([0u8; 32]), + "seed", + &pw("pw"), + b"", + KdfParams::floor_target(), + [0x11u8; SALT_LEN], + [0x22u8; NONCE_LEN], + ) + .unwrap(); + let actual = blob.len(); + assert!( + actual + SAFETY_MARGIN <= MAX_ENVELOPE_OVERHEAD, + "overhead {} + margin {} exceeds const {}", + actual, + SAFETY_MARGIN, + MAX_ENVELOPE_OVERHEAD + ); + assert!( + MAX_ENVELOPE_OVERHEAD - actual < 64, + "MAX_ENVELOPE_OVERHEAD {} is more than 64 B above the runtime measurement {} — tighten it", + MAX_ENVELOPE_OVERHEAD, + actual + ); + } + + // ===== Decoder: dispatch / wire-flip / fuzz / property ===== + + use crate::secrets::file::crypto::{ + ARGON2_MAX_M_KIB, ARGON2_MAX_T, ARGON2_READ_MAX_M_KIB, ARGON2_READ_MAX_T, + }; + use crate::secrets::wire::config::WIRE_CONFIG; + use subtle::ConstantTimeEq; + + /// Decode a real envelope so wire-flip tests can mutate one field + /// and re-encode. + fn decode(blob: &[u8]) -> Envelope { + bincode::decode_from_slice::(blob, WIRE_CONFIG) + .unwrap() + .0 + } + + fn encode(envelope: &Envelope) -> Vec { + bincode::encode_to_vec(envelope, WIRE_CONFIG).unwrap() + } + + /// Build a fresh scheme-1 envelope (under wid(1)/"seed"/pw=`p`) and + /// hand back the bytes for mutation tests. + fn scheme1_blob(p: &SecretString) -> Vec { + wrap_with_params(&wid(1), "seed", Some(p), b"seed", KdfParams::floor_target()) + .unwrap() + .expose_secret() + .to_vec() + } + + /// TC-001 — scheme-0 round-trip preserves plaintext. + #[test] + fn scheme0_round_trip_preserves_plaintext() { + let blob = wrap_with_params( + &wid(1), + "seed", + None, + b"top secret seed bytes", + KdfParams::floor_target(), + ) + .unwrap(); + let got = unwrap(&wid(1), "seed", None, blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), b"top secret seed bytes"); + } + + /// TC-002 — scheme-1 round-trip preserves plaintext. + #[test] + fn scheme1_round_trip_preserves_plaintext() { + let p = pw("hunter2"); + let blob = wrap_with_params( + &wid(7), + "seed", + Some(&p), + b"correct horse battery staple", + KdfParams::floor_target(), + ) + .unwrap(); + assert_ne!(blob.expose_secret(), b"correct horse battery staple"); + let got = unwrap(&wid(7), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), b"correct horse battery staple"); + } + + /// TC-003 — scheme-1 produces a fresh salt + nonce per wrap. + #[test] + fn scheme1_uses_fresh_salt_and_nonce_per_wrap() { + let p = pw("pw"); + let a = scheme1_blob(&p); + let b = scheme1_blob(&p); + let (sa, na) = match decode(&a).payload { + Payload::Password { salt, nonce, .. } => (salt, nonce), + _ => panic!("scheme-1 wrap must yield Password"), + }; + let (sb, nb) = match decode(&b).payload { + Payload::Password { salt, nonce, .. } => (salt, nonce), + _ => panic!("scheme-1 wrap must yield Password"), + }; + assert_ne!(sa, sb, "salt must be fresh per wrap"); + assert_ne!(na, nb, "nonce must be fresh per wrap"); + } + + /// TC-004 — wrong object password yields WrongPassword. + #[test] + fn wrong_password_fails_closed() { + let blob = scheme1_blob(&pw("right")); + let err = unwrap(&wid(1), "seed", Some(&pw("wrong")), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// Mutate the `Payload::Password` body in-place via decode → patch + /// → encode. Returns the new blob. + fn mutate_scheme1( + blob: &[u8], + patch: impl FnOnce(&mut KdfParamsEncoded, &mut [u8; SALT_LEN], &mut [u8; NONCE_LEN]), + ) -> Vec { + let mut env = decode(blob); + match env.payload { + Payload::Password { + ref mut kdf, + ref mut salt, + ref mut nonce, + .. + } => patch(kdf, salt, nonce), + _ => panic!("mutate_scheme1 expects a Password payload"), + } + encode(&env) + } + + /// TC-005 — wire-flip of kdf.m_kib (in-bounds shift) yields WrongPassword. + #[test] + fn wire_flip_kdf_m_kib_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.m_kib = ARGON2_MIN_M_KIB + 1024; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-006 — wire-flip of kdf.t (in-bounds shift) yields WrongPassword. + #[test] + fn wire_flip_kdf_t_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.t = ARGON2_MIN_T + 1; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-007 — wire-flip of kdf.id to an unknown value is rejected by + /// `enforce_bounds` BEFORE `derive_key` allocates. + #[test] + fn wire_flip_kdf_id_unknown_rejected_pre_derive() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.id = 7; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// TC-008 — wire-flip of salt[0] yields WrongPassword. + #[test] + fn wire_flip_salt_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |_, salt, _| { + salt[0] ^= 0x01; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-009 — wire-flip of nonce[0] yields WrongPassword. + #[test] + fn wire_flip_nonce_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |_, _, nonce| { + nonce[0] ^= 0x01; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-010 — re-binding the unwrap to a different wallet_id rejects. + #[test] + fn relocation_across_wallet_id_rejected() { + let p = pw("pw"); + let blob = wrap_with_params( + &wid(0xA), + "seed", + Some(&p), + b"seed", + KdfParams::floor_target(), + ) + .unwrap(); + let err = unwrap(&wid(0xB), "seed", Some(&p), blob.expose_secret()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-011 — re-binding the unwrap to a different label rejects. + #[test] + fn relocation_across_label_rejected() { + let p = pw("pw"); + let blob = wrap_with_params( + &wid(1), + "labelA", + Some(&p), + b"seed", + KdfParams::floor_target(), + ) + .unwrap(); + let err = unwrap(&wid(1), "labelB", Some(&p), blob.expose_secret()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-012 — wire-flip of envelope.version (via re-encode) is gated + /// to UnsupportedEnvelopeVersion before AAD bind. + #[test] + fn wire_flip_version_rejected_pre_aad() { + let blob = scheme1_blob(&pw("pw")); + let mut env = decode(&blob); + env.version = 2; + let tampered = encode(&env); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &tampered).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::UnsupportedEnvelopeVersion { found: 2 } + ), + "got {err:?}" + ); + } + + /// TC-013 — forged `Payload::Unprotected` with ciphertext bytes + + /// `Some(pw)` redirects to ExpectedProtectedButUnsealed. + #[test] + fn wire_flip_scheme_dispatch_redirects_safely() { + let env = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(vec![0xDEu8; 32]), + }; + let blob = encode(&env); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::ExpectedProtectedButUnsealed), + "got {err:?}" + ); + } + + /// TC-017 — truncated blob (< minimum envelope length) yields + /// Corruption. + #[test] + fn truncated_blob_yields_corruption() { + let blob = scheme1_blob(&pw("pw")); + let cut = blob.len() / 2; + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &blob[..cut]).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + } + + /// TC-018 — random-byte blob yields Corruption (both arms). + #[test] + fn random_garbage_yields_corruption() { + let garbage = b"NOTANEVELOPE........................."; + let err = unwrap(&wid(1), "seed", None, garbage).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), garbage).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + } + + /// TC-019 — a manually-built envelope at version=2 fails closed + /// regardless of password. + #[test] + fn unsupported_version_rejected_for_any_password() { + let env = Envelope { + version: 2, + payload: Payload::Unprotected(b"x".to_vec()), + }; + let blob = encode(&env); + for arg in [None, Some(&pw("pw"))] { + let err = unwrap(&wid(1), "seed", arg, &blob).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::UnsupportedEnvelopeVersion { found: 2 } + ), + "got {err:?}" + ); + } + } + + /// A version above 255 must surface its FULL `u32`, not a truncated `u8` + /// (`300 as u8 == 44` would alias a different version in diagnostics). + #[test] + fn unsupported_version_preserves_full_u32() { + let env = Envelope { + version: 300, + payload: Payload::Unprotected(b"x".to_vec()), + }; + let blob = encode(&env); + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::UnsupportedEnvelopeVersion { found: 300 } + ), + "version must not be truncated to u8, got {err:?}" + ); + } + + /// TC-020 — a hand-crafted byte stream with an unknown payload + /// enum tag yields Corruption (bincode's natural fail-closed). + #[test] + fn unknown_scheme_discriminant_yields_corruption() { + // envelope.version = 1 (varint = 0x01) then a Payload enum tag + // of 7 (varint = 0x07) — the two-variant enum decode rejects. + let blob = [0x01u8, 0x07]; + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + } + + /// TC-021 — Some(pw) + scheme-0 yields ExpectedProtectedButUnsealed. + #[test] + fn some_pw_on_scheme0_fails_closed() { + let blob = wrap_with_params( + &wid(1), + "seed", + None, + b"attacker-seed", + KdfParams::floor_target(), + ) + .unwrap(); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), blob.expose_secret()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::ExpectedProtectedButUnsealed), + "got {err:?}" + ); + } + + /// TC-022 — None + scheme-1 yields NeedsPassword. + #[test] + fn none_pw_on_scheme1_yields_needs_password() { + let blob = scheme1_blob(&pw("pw")); + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::NeedsPassword), + "got {err:?}" + ); + } + + /// TC-023 — inflated KDF param rejected by `enforce_bounds` before + /// `derive_key` allocates (a ~4 TiB allocation would OOM the test). + #[test] + fn kdf_enforce_bounds_rejects_before_derive() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.m_kib = u32::MAX; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.t = ARGON2_MAX_T + 1; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// TC-024 — the per-read ceiling rejects an envelope whose `m_kib` + /// exceeds `ARGON2_READ_MAX_M_KIB` even when still inside + /// `enforce_bounds`. Catches inflated headers BEFORE `derive_key`. + #[test] + fn per_read_ceiling_rejects_inflated_header() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let bumped = ARGON2_READ_MAX_M_KIB * 2; + // Sanity: the bumped value stays inside the wider enforce_bounds + // ceiling, so only the per-read gate can refuse it. + assert!(bumped <= ARGON2_MAX_M_KIB); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.m_kib = bumped; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// Sibling to TC-024 on the `t` axis — the per-read ceiling rejects + /// an envelope whose `t` exceeds `ARGON2_READ_MAX_T` even when still + /// inside `enforce_bounds` (`ARGON2_MAX_T = 16`). Closes the CPU-axis + /// gap that would otherwise let a forged header run Argon2 at 5.3× + /// the shipped iteration count. + #[test] + fn kdf_t_ceiling_fires_before_derive() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let bumped_t = ARGON2_READ_MAX_T + 1; + // Sanity: the bumped t stays inside the wider enforce_bounds + // ceiling, so only the per-read gate can refuse it. + assert!(bumped_t <= ARGON2_MAX_T); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + // Keep m_kib at the ceiling so the m_kib gate cannot fire — + // t must be the sole reason this rejects. + kdf.m_kib = ARGON2_READ_MAX_M_KIB; + kdf.t = bumped_t; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// Every early return in `unwrap` routes through `Payload::wipe`, so + /// the wipe itself is the thing worth pinning: a decoded scheme-0 + /// payload is the raw secret on an unguarded heap `Vec`. + #[test] + fn payload_wipe_clears_both_variants() { + let mut unprotected = Payload::Unprotected(b"seed material".to_vec()); + unprotected.wipe(); + match &unprotected { + Payload::Unprotected(bytes) => { + assert_eq!(bytes.len(), b"seed material".len(), "length must survive"); + assert!(bytes.iter().all(|b| *b == 0), "plaintext survived the wipe"); + } + Payload::Password { .. } => unreachable!("variant must not change"), + } + + let mut protected = Payload::Password { + kdf: KdfParamsEncoded::from(KdfParams::floor_target()), + salt: [1u8; SALT_LEN], + nonce: [2u8; NONCE_LEN], + ciphertext: vec![0xAB; 32], + }; + protected.wipe(); + match &protected { + Payload::Password { ciphertext, .. } => { + assert!( + ciphertext.iter().all(|b| *b == 0), + "ciphertext survived the wipe" + ); + } + Payload::Unprotected(_) => unreachable!("variant must not change"), + } + } + + /// Trailing bytes appended after a valid envelope are rejected as + /// `Corruption` — defends against a truncation/extension probe. + #[test] + fn decode_rejects_trailing_garbage() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let mut extended = blob.clone(); + extended.extend_from_slice(&[0xFFu8; 16]); + let err = unwrap(&wid(1), "seed", Some(&p), &extended).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + + // The same blob without the suffix still unwraps cleanly — + // proves the rejection is on the trailing bytes, not the + // envelope itself. + let ok = unwrap(&wid(1), "seed", Some(&p), &blob).unwrap(); + assert_eq!(ok.expose_secret(), b"seed"); + } + + /// TC-031 — round-tripped secret matches the original under a + /// constant-time compare. + #[test] + fn round_trip_is_constant_time_equal() { + let p = pw("pw"); + let original = SecretBytes::from_slice(b"seed material"); + let blob = wrap_with_params( + &wid(1), + "seed", + Some(&p), + original.expose_secret(), + KdfParams::floor_target(), + ) + .unwrap(); + let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert!(bool::from(got.ct_eq(&original))); + } + + /// TC-035 (round-trip half) — scheme-1 at exact MAX_PLAINTEXT_LEN + /// round-trips and the enveloped bytes fit the backend cap. + #[test] + fn scheme1_at_cap_round_trips_within_backend_cap() { + let p = pw("pw"); + let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let blob = + wrap_with_params(&wid(1), "seed", Some(&p), &pt, KdfParams::floor_target()).unwrap(); + assert!(blob.len() <= MAX_SECRET_LEN); + let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), &pt[..]); + } + + /// TC-037 — scheme-0 decode rejects an oversize plaintext (> MAX_PLAINTEXT_LEN) + /// even though the blob fits within DECODE_BUDGET. A tampered blob that encodes + /// a plaintext between MAX_PLAINTEXT_LEN+1 and DECODE_BUDGET would otherwise + /// bypass the wrap-side cap on the decode path. + #[test] + fn scheme0_decode_rejects_oversize_plaintext() { + // Build a scheme-0 envelope with plaintext = MAX_PLAINTEXT_LEN + 1 bytes. + // encode_envelope bypasses the wrap-side cap (it is a raw encoder), so this + // creates the exact tampered-blob scenario. + let oversized = vec![0x5Au8; MAX_PLAINTEXT_LEN + 1]; + let env = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(oversized.clone()), + }; + let blob = encode(&env); + // Confirm the blob fits within DECODE_BUDGET (otherwise the test proves nothing). + assert!( + blob.len() <= DECODE_BUDGET, + "test blob must fit within DECODE_BUDGET to prove the plaintext check fires" + ); + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + ), + "expected SecretTooLarge on oversized scheme-0 plaintext, got {err:?}" + ); + } + + /// TC-038 — scheme-0 decode accepts a plaintext at exactly MAX_PLAINTEXT_LEN. + #[test] + fn scheme0_decode_accepts_at_cap_plaintext() { + let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let env = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(at_cap.clone()), + }; + let blob = encode(&env); + let got = unwrap(&wid(1), "seed", None, &blob).unwrap(); + assert_eq!(got.expose_secret(), &at_cap[..]); + } + + /// TC-036 — value rollback is intentionally NOT defended. + #[test] + fn value_rollback_is_not_defended() { + let p = pw("pw"); + let old = wrap_with_params( + &wid(1), + "seed", + Some(&p), + b"OLD-VALUE", + KdfParams::floor_target(), + ) + .unwrap(); + let _new = wrap_with_params( + &wid(1), + "seed", + Some(&p), + b"NEW-VALUE", + KdfParams::floor_target(), + ) + .unwrap(); + let got = unwrap(&wid(1), "seed", Some(&p), old.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), b"OLD-VALUE"); + } + + /// TC-032 — random byte mutations and truncations never panic; + /// every outcome is a permitted typed variant. + #[test] + fn fuzz_byte_mutation_and_truncation_never_panics() { + let p = pw("fuzz-pw"); + let valid = scheme1_blob(&p); + // Pristine envelope unwraps cleanly. + assert_eq!( + unwrap(&wid(1), "seed", Some(&p), &valid) + .unwrap() + .expose_secret(), + b"seed" + ); + + let mut state: u32 = 0x9E37_79B9; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state + }; + + let assert_typed = |arg: Option<&SecretString>, buf: &[u8]| { + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unwrap(&wid(1), "seed", arg, buf) + })) + .expect("unwrap must never panic on hostile input"); + match res { + Ok(_) + | Err(SecretStoreError::Corruption) + | Err(SecretStoreError::WrongPassword) + | Err(SecretStoreError::NeedsPassword) + | Err(SecretStoreError::ExpectedProtectedButUnsealed) + | Err(SecretStoreError::UnsupportedEnvelopeVersion { .. }) + | Err(SecretStoreError::KdfFailure) + // A mutated scheme-0 blob can decode a plaintext Vec that + // exceeds MAX_PLAINTEXT_LEN while still fitting DECODE_BUDGET. + | Err(SecretStoreError::SecretTooLarge { .. }) => {} + Err(other) => panic!("unexpected error variant: {other:?}"), + } + }; + + for i in 0..2_000 { + let mut buf = valid.clone(); + let flips = 1 + (next() % 4) as usize; + for _ in 0..flips { + let idx = (next() as usize) % buf.len(); + buf[idx] ^= (next() & 0xFF) as u8; + } + // None path every iteration (cheap, no derive). + assert_typed(None, &buf); + // Some path on a representative subset (each may derive). + if i % 16 == 0 { + assert_typed(Some(&p), &buf); + } + } + + // Truncation at every offset — a short read must never panic. + for cut in 0..valid.len() { + assert_typed(None, &valid[..cut]); + assert_typed(Some(&p), &valid[..cut]); + } + } + + // TC-040 — proptest: no single-byte flip surfaces the plaintext. + // Minimises to the offset that breaks coverage if one exists. + proptest::proptest! { + #[test] + fn prop_single_byte_flip_never_yields_plaintext( + (offset, mask) in (0usize..200usize, 1u8..=255u8), + ) { + // Re-built per case so the proptest harness can shrink + // independently of the host RNG. + let plaintext: &[u8] = b"goldfinch"; + let p = pw("pw"); + let valid = wrap_with_params(&wid(1), "seed", Some(&p), plaintext, KdfParams::floor_target()) + .unwrap() + .expose_secret() + .to_vec(); + if offset >= valid.len() { + // Out-of-bounds offset → skip via prop_assume so proptest + // shrinks toward in-bounds offsets. + proptest::prop_assume!(offset < valid.len()); + } + let mut buf = valid.clone(); + buf[offset] ^= mask; + match unwrap(&wid(1), "seed", Some(&p), &buf) { + Ok(secret) => { + proptest::prop_assert_ne!( + secret.expose_secret(), + plaintext, + "single-byte flip at offset {} surfaced the plaintext", + offset + ); + } + Err(_) => { /* any typed error is fine */ } + } + } + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs new file mode 100644 index 00000000000..e869b29159b --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs @@ -0,0 +1,56 @@ +//! Bincode-encoded wire image of [`KdfParams`] — the Argon2 parameter +//! header read out of every scheme-1 envelope. +//! +//! Kept as a separate type from [`KdfParams`] (the in-memory + JSON- +//! vault type) so the wire layer owns its own bincode derives and the +//! in-memory type keeps its serde derives for the human-debuggable JSON +//! vault format. + +use crate::secrets::error::SecretStoreError; +use crate::secrets::file::crypto::KdfParams; + +/// Wire image of [`KdfParams`]: `id ‖ m_kib ‖ t ‖ p`, each a fixed- +/// width integer under the bincode varint config. Encoded once into +/// every scheme-1 envelope's `Payload::Password` body AND into the +/// scheme-1 AAD, so the two cannot disagree without failing the tag. +#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) struct KdfParamsEncoded { + /// Argon2 algorithm discriminator (only `KDF_ID_ARGON2ID = 1` + /// today; enforced by [`KdfParams::enforce_bounds`]). + pub id: u8, + /// Argon2 memory cost (KiB). Bounded. + pub m_kib: u32, + /// Argon2 time cost (iterations). Bounded. + pub t: u32, + /// Argon2 parallelism. Pinned to 1. + pub p: u32, +} + +impl From for KdfParamsEncoded { + fn from(k: KdfParams) -> Self { + Self { + id: k.id, + m_kib: k.m_kib, + t: k.t, + p: k.p, + } + } +} + +impl TryFrom for KdfParams { + type Error = SecretStoreError; + + /// Convert the wire image into the in-memory [`KdfParams`], gated on + /// [`KdfParams::enforce_bounds`] so an inflated header never + /// reaches `derive_key`. + fn try_from(k: KdfParamsEncoded) -> Result { + let out = KdfParams { + id: k.id, + m_kib: k.m_kib, + t: k.t, + p: k.p, + }; + out.enforce_bounds()?; + Ok(out) + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs new file mode 100644 index 00000000000..d53ff9bbb96 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs @@ -0,0 +1,36 @@ +//! Bincode wire format for the Tier-2 envelope and the three AAD +//! constructions used inside `secrets/`. +//! +//! Every byte that crosses the AEAD seam — the on-disk Tier-2 blob and the +//! AAD bound into each ciphertext — is produced by a `#[derive(bincode:: +//! Encode)]` (or `Encode + Decode`) struct in this module, against the +//! single [`config::WIRE_CONFIG`] constant. A future bincode-config drift +//! is then caught by the golden vector tests in [`envelope::tests`] +//! instead of silently corrupting every stored blob. +//! +//! Module is `pub(crate)` only — the Tier-2 wire format is an +//! implementation detail of [`SecretStore`](super::store::SecretStore); +//! external callers see the unchanged `set_secret` / `get_secret` API. +//! +//! Audit-readable layout: +//! +//! - [`config`] — the single bincode config + domain-tag / version +//! constants every encoder uses. +//! - [`kdf`] — `KdfParamsEncoded`, the wire image of [`KdfParams`]. +//! - [`aad`] — the three AAD structs (`Tier2Aad` / `EntryAad` / +//! `VerifyAad`). +//! - [`envelope`] — the `Envelope` + `Payload` structs plus the +//! `wrap` / `unwrap` API. +//! +//! [`KdfParams`]: super::file::crypto::KdfParams +//! +//! Domain tags include an explicit `-v2` suffix to mark the +//! wire-format break from the pre-bincode hand-rolled layout +//! (`PWSEV-TIER2-AAD-v1` and the implicitly-untagged +//! `secrets/file/format.rs::aad` / `verify_aad` outputs). +#![deny(missing_docs)] + +pub(crate) mod aad; +pub(crate) mod config; +pub(crate) mod envelope; +pub(crate) mod kdf; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs index 064ee3a23a2..f83b1c6de66 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs @@ -10,13 +10,41 @@ 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; +use crate::sqlite::util::permissions::{apply_secure_permissions, reject_symlink}; -/// 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. +struct CreatedDestinationGuard { + path: PathBuf, + armed: bool, +} + +impl CreatedDestinationGuard { + fn new(path: &Path) -> Self { + Self { + path: path.to_path_buf(), + armed: false, + } + } + + fn arm(&mut self) { + self.armed = true; + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for CreatedDestinationGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// Fsync `path`'s parent dir on Unix so the rename's dentry update is +/// durable across power loss (`persist` only fsyncs the file inode; the +/// dentry is journalled separately). No-op on non-Unix. #[cfg(unix)] fn fsync_parent_dir(path: &Path) -> Result<(), WalletStorageError> { if let Some(parent) = path.parent() { @@ -45,65 +73,114 @@ fn map_source_open_err(err: WalletStorageError) -> WalletStorageError { } /// Distinguishes auto-backup filenames. +/// +/// `PreMigration` and `PreRestore` carry `db_stem` — the source database's +/// sanitized filename stem (see [`sanitize_db_stem`]). Sibling databases +/// share one auto-backup directory by default (`/backups/auto/`), +/// and the second-resolution timestamp alone cannot separate two of them +/// backed up within the same second. `PreDelete` is discriminated by its +/// wallet id instead. #[derive(Debug, Clone, Copy)] -pub enum BackupKind { - PreMigration { from: i32, to: i32 }, - PreDelete { wallet_id: WalletId }, - PreRestore, +pub enum BackupKind<'a> { + PreMigration { + db_stem: &'a str, + from: i32, + to: i32, + }, + PreDelete { + wallet_id: WalletId, + }, + PreRestore { + db_stem: &'a str, + }, } +/// Longest database filename stem embedded in a backup filename. +const MAX_DB_STEM_LEN: usize = 32; +/// Stand-in for a database path with no usable filename stem. +const FALLBACK_DB_STEM: &str = "db"; + /// Filename for `backup_to(directory)`. -pub fn manual_backup_filename() -> String { +pub(crate) 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(); +/// Filename for an auto-backup, stamped with the current UTC time. +pub(crate) fn auto_backup_filename(kind: BackupKind<'_>) -> String { + auto_backup_filename_at(kind, &utc_timestamp()) +} + +/// [`auto_backup_filename`] with the timestamp supplied, so tests can pin it. +/// +/// Every kind's discriminator precedes `ts`, keeping `ts` the last +/// `-`-delimited token that [`backup_timestamp`] reads back. +fn auto_backup_filename_at(kind: BackupKind<'_>, ts: &str) -> String { match kind { - BackupKind::PreMigration { from, to } => format!("pre-migration-{from}-to-{to}-{ts}.db"), + BackupKind::PreMigration { db_stem, from, to } => { + format!("pre-migration-{db_stem}-{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"), + BackupKind::PreRestore { db_stem } => format!("pre-restore-{db_stem}-{ts}.db"), + } +} + +/// Sanitize `db_path`'s filename stem for embedding in a backup filename. +/// +/// Keeps `[A-Za-z0-9_-]` from `Path::file_stem` (lossy UTF-8), maps every +/// other character to `_`, truncates to 32 characters, and falls back to +/// `"db"` when the path has no stem. The allowlist leaves a plain ASCII +/// filename component — no separators, no `.` (so `..` becomes `__`), no +/// NUL, control, or non-ASCII bytes — that cannot escape the backup +/// directory. `-` survives for readability and is parse-safe because the +/// stem precedes the timestamp. +/// +/// The mapping is deliberately lossy: two stems differing only outside the +/// allowlist, or sharing their first 32 characters, yield the same token. +/// That degrades to a refused overwrite +/// ([`WalletStorageError::BackupDestinationExists`]), never to a silently +/// replaced backup. +pub(crate) fn sanitize_db_stem(db_path: &Path) -> String { + let sanitized: String = db_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .chars() + .take(MAX_DB_STEM_LEN) + .map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' => c, + _ => '_', + }) + .collect(); + if sanitized.is_empty() { + FALLBACK_DB_STEM.to_string() + } else { + sanitized } } -/// Take an online backup of `src` to `dest`. Uses the -/// `rusqlite::backup::Backup::run_to_completion` page-stepping API -/// so writers aren't blocked. +/// Take an online backup of `src` to `dest` via the page-stepping +/// `Backup::run_to_completion` 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> { +/// The copy is staged in a `NamedTempFile` next to `dest` and +/// `persist_noclobber`-ed over `dest` only on success, so a failure never +/// materialises a partial `.db`. A pre-existing `dest` is rejected +/// atomically (no TOCTOU window), and the parent dir is fsynced afterward. +pub(crate) 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)?; } } - // 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. + // Stage in an unguessable temp file in the same dir; the same-FS + // guarantee makes `persist` an atomic rename. let parent = dest.parent().unwrap_or(Path::new(".")); let tmp = tempfile::NamedTempFile::new_in(parent)?; - // 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. + // chmod 0o600 BEFORE persist so the destination inherits owner-only + // mode via the rename; chmod after would leave an observable window. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -111,8 +188,6 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { .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)?; { @@ -120,15 +195,12 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { // 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. + // Close before persisting so SQLite flushes its WAL/SHM siblings + // against the temp path; the rename then sweeps them away. drop(backup_conn); - // `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. + // Atomic check-and-rename with no TOCTOU window; `AlreadyExists` maps + // to the typed `BackupDestinationExists` overwrite-refusal contract. tmp.persist_noclobber(dest).map_err(|e| { if e.error.kind() == std::io::ErrorKind::AlreadyExists { WalletStorageError::BackupDestinationExists { @@ -138,86 +210,76 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { WalletStorageError::Io(e.error) } })?; - // 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)?; - // 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. + // Re-tighten for non-Unix builds; 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. +/// Restore a `.db` backup over `dest_db_path`. The caller must guarantee +/// the destination is not held open by this process and owns 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. +/// Validation runs against the source and again against the STAGED bytes, +/// under SQLite `locking_mode=EXCLUSIVE` plus `BEGIN EXCLUSIVE` on +/// `dest_db_path`, blocking every other SQLite peer (which advisory flock +/// could not). The +/// store-generation token is rotated INTO the staged temp before the swap, +/// so the single commit point brings in the restored bytes and the fresh +/// token together — a peer never observes restored content carrying the +/// source's stale token. The staged temp is `persist`-ed as an atomic rename +/// only after all gates pass, and that rename is the commit point: if it +/// fails, the live DB and its WAL/SHM siblings are left untouched, so a failed +/// restore never strands the old DB without its WAL-committed state. The +/// now-stale WAL/SHM siblings are unlinked only AFTER the swap succeeds (so a +/// leftover `-wal` can't shadow the restored DB); the parent dir is fsynced +/// afterward. See the numbered steps in the body for the per-phase rationale. +/// When the destination did not exist, an owner-only placeholder may remain if +/// failure occurs before exclusion is acquired or after it is released. /// /// # Lock-release-before-rename trade-off /// -/// The EXCLUSIVE lock is released BEFORE the atomic rename, on -/// purpose. SQLite keeps a kernel file handle on the destination's -/// (old) inode for as long as the lock conn is alive; holding that -/// handle across the rename would leave it pointing at the unlinked -/// old inode while peers opening the new path would race the rename -/// itself (on some filesystems the rename can outright fail). -/// Releasing the lock first lets SQLite drop its old-inode handle -/// before the rename swaps it. +/// The EXCLUSIVE lock is dropped just BEFORE the rename: SQLite holds a +/// kernel handle on the old inode while the lock conn is alive, and +/// holding it across the rename would point it at the unlinked inode and +/// can make the rename fail on some filesystems. The cost is a microsecond +/// window where a peer could write into the old inode the rename then +/// unlinks — its own write is lost, nothing escalates. Correct file-handle +/// semantics across the rename outweigh absolute lock coverage. +/// +/// # Source trust /// -/// The trade-off: a microsecond window opens between lock release and -/// rename in which a peer can acquire its own SQLite lock on the -/// destination's old inode. Any writes it makes within that window -/// land in the old inode, which the rename immediately unlinks — the -/// peer's writes are effectively dropped on the floor (the peer keeps -/// a handle on an inode that no longer has any directory entry; once -/// it closes, the bytes are reclaimed). That is acceptable for the -/// restore contract: callers serialize their own restore intent at -/// the application layer; the window is too short for a non-malicious -/// peer to land more than a transient miss, and a malicious peer -/// cannot escalate beyond losing its own write. Correct file-handle -/// semantics across the rename matter more than absolute lock -/// coverage. -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. +/// Integrity, wallet application identity, and schema compatibility do not +/// authenticate provenance. Restore trusts a valid source as much as the live +/// database; protect the backup directory from replacement or modification. +pub(crate) fn restore_from( + dest_db_path: &Path, + src_backup: &Path, +) -> Result<(), WalletStorageError> { + let parent = dest_db_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + crate::parent_permissions::check_parent_perms(parent).map_err(|error| match error { + crate::parent_permissions::ParentPermissionsError::Io(source) => { + WalletStorageError::Io(source) + } + crate::parent_permissions::ParentPermissionsError::Insecure { ancestor, reason } => { + WalletStorageError::InsecureParentDir { ancestor, reason } + } + })?; + // Ahead of the placeholder block below: `exists()` follows a link to a + // live target, so a planted symlink would skip that block entirely and + // be opened — and restored over — directly. + reject_symlink(dest_db_path)?; + + // 1. Cheap early-out: sniff integrity + schema-history + version + + // wallet-identity against the source so an incompatible input fails + // before we stream the whole file. The authoritative, TOCTOU-safe + // gate re-runs on the STAGED bytes (step 4). 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 { @@ -227,59 +289,76 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet return Err(WalletStorageError::SchemaHistoryMissing); } crate::sqlite::migrations::assert_schema_version_supported(&src)?; + crate::sqlite::conn::assert_wallet_application_id_or_legacy(&src)?; + crate::sqlite::migrations::assert_schema_history_well_formed(&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); + // 2. SQLite-native exclusion: exclusive locking mode makes readers back + // off even when the destination uses WAL, where BEGIN EXCLUSIVE alone + // excludes writers but normally permits readers. For a missing + // destination, create an owner-only placeholder first so a peer cannot + // create and write the path during staging. A failure before lock + // release removes a placeholder created by this call while exclusion + // is still held. The short-lived connection is dropped before + // `persist` (see lock-release trade-off). + let mut dest_lock_conn: Option; + let mut created_destination = None; + if !dest_db_path.exists() { + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(dest_db_path) { + Ok(file) => { + created_destination = Some(CreatedDestinationGuard::new(dest_db_path)); + apply_secure_permissions(dest_db_path)?; + drop(file); } - Err(other) => return Err(WalletStorageError::Sqlite(other)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(WalletStorageError::Io(error)), } - } else { - None + } + + let conn = + crate::sqlite::conn::open_conn(dest_db_path, crate::sqlite::conn::Access::ReadWrite)?; + // The destination has no persister yet (the persister is the + // caller), so apply our own busy_timeout for a backoff window. + conn.busy_timeout(std::time::Duration::from_secs(5))?; + conn.pragma_update(None, "locking_mode", "EXCLUSIVE")?; + // BUSY after busy_timeout becomes `RestoreDestinationLocked` so + // callers keep their existing branch. + dest_lock_conn = 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)), }; + if let Some(guard) = created_destination.as_mut() { + guard.arm(); + } - // 3. Stage the source into a NamedTempFile in the destination's - // parent dir (unguessable name, no symlink-plant TOCTOU). + // 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. 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. + // 4. Re-validate the STAGED bytes before persisting: a torn + // `io::copy` that escaped `sync_all` would otherwise persist a + // corrupt DB, and the recheck failing just drops the temp. Bound to + // the staged bytes (not the source handle) so a swap during the + // restore window can't slip a forward-version or foreign DB through. { let staged = crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadOnly) @@ -287,19 +366,37 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet 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)?; + crate::sqlite::conn::assert_wallet_application_id_or_legacy(&staged)?; + crate::sqlite::migrations::assert_schema_history_well_formed(&staged)?; } - // 5. chmod 600 on the temp BEFORE persist so the destination - // inherits owner-only mode via the atomic rename. Chmodding - // post-persist would leave the new DB live at the destination on - // a chmod failure, contradicting the rolled-back error. + // 5. Regenerate the store-generation token INTO the staged temp, before + // the atomic rename, so the single commit point (step 8) swaps in the + // restored bytes and the rotated token together — there is no window + // where restored content is observable with the source's stale token. + // The staged DB is switched to DELETE journaling first so the UPDATE + // lands in the main file with no `-wal` frames stranded outside the + // rename; the reopened destination is forced back to its configured + // journal mode on its next open. A pre-V009 backup has no generation + // table; `regenerate_generation` is a no-op there and the token is + // (re)seeded on its later migration to V009. + { + let conn = + crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadWrite)?; + conn.pragma_update(None, "journal_mode", "DELETE")?; + crate::sqlite::schema::versions::regenerate_generation(&conn)?; + drop(conn); + // Durably flush the regenerated token before the rename commits it. + tmp.as_file().sync_all()?; + } + + // 6. chmod 0o600 on the temp BEFORE persist so the destination + // inherits owner-only mode via the rename (post-persist chmod could + // fail with the new DB already live). #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -307,33 +404,41 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet .set_permissions(std::fs::Permissions::from_mode(0o600))?; } - // 6. Release the SQLite-native EXCLUSIVE lock BEFORE touching the - // on-disk WAL/SHM siblings or running the rename. On Windows / - // some FUSE / AV-scanned mounts, `remove_file` against a file - // still held open by another handle on the same process returns - // `PermissionDenied`; on Unix the unlinked inodes remain - // reachable through the open fd but the rename window still - // benefits from a clean close. + // 7. Release the EXCLUSIVE lock before the rename/unlinks: on Windows / + // some FUSE mounts `remove_file` on a still-open file returns + // `PermissionDenied`, and the rename window wants a clean close (see + // lock-release trade-off above). Stop failure cleanup from removing a + // path after this point: a peer may legitimately acquire it once the + // lock is gone. If persist then fails, the owner-only placeholder stays. + if let Some(guard) = created_destination.as_mut() { + guard.disarm(); + } 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); } - // 7. Atomicity gate: every staged-file validation has now passed - // and our writer handle is closed, 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. - // - // 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). Ordering requires the dest lock conn to be dropped - // first so cross-platform unlink semantics hold. + // 8. Persist the staged DB atomically over the destination. The atomic + // rename is the single commit point: it swaps in both the restored + // bytes and the rotated generation token together. If it fails (disk + // full, EXDEV, perms) the live DB and its WAL/SHM siblings are left + // untouched, so a failed restore can never strand the old DB without + // its WAL-committed state. Sibling cleanup (step 9) runs only once the + // swap has succeeded. + tmp.persist(dest_db_path) + .map_err(|e| WalletStorageError::Io(e.error))?; + + // 9. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover + // `-wal` can't shadow the restored DB on the next open. Sibling paths + // use `OsString::push` so non-UTF-8 bytes round-trip; `NotFound` is a + // silent no-op. The lock conn was dropped in step 7 for cross-platform + // unlink semantics. + // INTENTIONAL(wal-shm-cleanup-race): this unlink shares the + // lock-release-before-rename trade-off documented on this function. A + // peer that opened the old database still holds its own descriptors, + // so under POSIX these unlinks just detach the names — the peer reads + // its already-open inode and nothing escalates. Accepted risk: that + // peer keeps serving pre-restore state until it reopens. if let Some(file_name) = dest_db_path.file_name() { for ext in ["-wal", "-shm"] { let mut sibling_name = file_name.to_os_string(); @@ -347,32 +452,31 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet } } - // 8. Persist atomically over the destination. - tmp.persist(dest_db_path) - .map_err(|e| WalletStorageError::Io(e.error))?; - - // 9. Fsync the destination's parent directory so the atomic rename's - // dentry update is durable across power loss (no-op on non-Unix). + // 10. Make the rename + unlink dentry updates durable. 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). + // 11. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm). 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`. +/// Diagnostic lines retained per integrity probe, matching SQLite's own +/// default `PRAGMA integrity_check` cap. /// -/// 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. +/// `integrity_check` self-limits; `foreign_key_check` does not — it emits +/// one row per violating child row across every table, so a file whose +/// `wallets` pages were lost yields one per row in the entire database. +/// Both walks are capped here so neither the retained report, the log +/// record built from it, nor the CLI's stderr can grow with the damage. +const MAX_INTEGRITY_REPORT_LINES: usize = 100; + +/// Run `PRAGMA integrity_check` and return `Ok(())` only on the single +/// row `"ok"`. Any other result becomes a typed `IntegrityCheckFailed` via +/// the caller-supplied builder; an underlying rusqlite error surfaces as +/// `IntegrityCheckRunFailed`. Rows are `\n`-joined so the report carries +/// every diagnostic, not just the first, up to +/// [`MAX_INTEGRITY_REPORT_LINES`]; beyond that a trailing line states how +/// many were suppressed. pub(crate) fn run_integrity_check( conn: &Connection, on_failure: F, @@ -384,20 +488,21 @@ where .prepare("PRAGMA integrity_check") .map_err(|source| WalletStorageError::IntegrityCheckRunFailed { source })?; let mut rows: Vec = Vec::new(); + let mut suppressed = 0usize; 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), + Ok(s) if rows.len() < MAX_INTEGRITY_REPORT_LINES => rows.push(s), + // Past the cap the stream is still drained so the suppressed + // count is exact, but nothing further is retained. + Ok(_) => suppressed += 1, 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`. + // SQLite can surface a `DatabaseCorrupt` partway through + // the stream; treat it as end-of-stream when we already + // have diagnostic rows, else surface it below. trailing_err = Some(e); break; } @@ -411,9 +516,25 @@ where return Err(on_failure(String::new())); } if rows.len() == 1 && rows[0] == "ok" && trailing_err.is_none() { - Ok(()) + // `integrity_check` validates page/index structure and says nothing + // about referential integrity, so a file can be structurally perfect + // while an identity's keys point at a wallet that no longer exists. + // SQLite also skips FK enforcement entirely for any child key with a + // NULL column (MATCH SIMPLE), so violations can accumulate on the + // nullable-scope tables without any write ever failing. + let violations = foreign_key_violations(conn)?; + if violations.is_empty() { + Ok(()) + } else { + Err(on_failure(violations.join("\n"))) + } } else { let mut report = rows.join("\n"); + if suppressed > 0 { + report.push_str(&format!( + "\n... and {suppressed} further integrity_check rows" + )); + } if let Some(e) = trailing_err { // Preserve the cut-off marker so operators see the stream // was truncated, not just under-reported. @@ -423,6 +544,49 @@ where } } +/// One diagnostic line per `PRAGMA foreign_key_check` row, empty when the +/// database is referentially clean. +/// +/// Each row is `(child table, child rowid, parent table, fk index)`; the +/// rowid is NULL for a WITHOUT ROWID child, so it renders as `-`. +/// +/// Capped at [`MAX_INTEGRITY_REPORT_LINES`] retained lines plus a trailing +/// count. The pragma has no cap of its own, so a broken parent table +/// yields one row per child row in the file. +fn foreign_key_violations(conn: &Connection) -> Result, WalletStorageError> { + let mut stmt = conn + .prepare("PRAGMA foreign_key_check") + .map_err(|source| WalletStorageError::IntegrityCheckRunFailed { source })?; + let rows = stmt + .query_map([], |row| { + let table: String = row.get(0)?; + let rowid: Option = row.get(1)?; + let parent: String = row.get(2)?; + let fkid: i64 = row.get(3)?; + Ok(format!( + "foreign_key_check: {table} row {} violates FK #{fkid} into {parent}", + rowid.map_or_else(|| "-".to_string(), |id| id.to_string()) + )) + }) + .map_err(|source| WalletStorageError::IntegrityCheckRunFailed { source })?; + let mut out = Vec::new(); + let mut suppressed = 0usize; + for item in rows { + let line = item.map_err(|source| WalletStorageError::IntegrityCheckRunFailed { source })?; + if out.len() < MAX_INTEGRITY_REPORT_LINES { + out.push(line); + } else { + suppressed += 1; + } + } + if suppressed > 0 { + out.push(format!( + "... and {suppressed} further foreign-key violations" + )); + } + Ok(out) +} + /// Apply retention to a directory. Files that match the recognised /// backup-name prefixes are eligible; others are ignored. /// @@ -434,7 +598,10 @@ where /// 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 { +pub(crate) 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 { @@ -458,24 +625,26 @@ pub fn prune(dir: &Path, policy: RetentionPolicy) -> Result = 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 { + // `keep_last_n` is a FLOOR: the N newest are always kept. `max_age` is + // an independent age window. A file is kept if it satisfies EITHER + // policy (the union), and removed only when it fails BOTH — so a + // within-age file beyond the N newest is still kept (the bug fix: the + // count must not cap the age window). With no policy set at all (both + // `None`) every file is kept. + let count_keep = matches!(policy.keep_last_n, Some(n) if idx < n); + let age_keep = match policy.max_age { Some(max) => now.duration_since(ts).map(|d| d <= max).unwrap_or(true), - None => true, + None => false, }; - if pass_count && pass_age { + let no_policy = policy.keep_last_n.is_none() && policy.max_age.is_none(); + if no_policy || count_keep || age_keep { kept += 1; } else { match std::fs::remove_file(&path) { Ok(()) => removed.push(path), Err(e) => { - // 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`. + // A failed removal leaves the file on disk, so count it + // as kept to preserve `kept + removed == total`. failed_removals.push((path, e)); kept += 1; } @@ -543,6 +712,108 @@ fn utc_timestamp() -> String { mod tests { use super::*; + /// A structurally sound file can still be referentially broken: + /// `integrity_check` alone reports "ok" while a child row points at a + /// parent that does not exist. The verification path must catch that. + #[test] + fn integrity_check_reports_foreign_key_violations() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + // Plant an orphan with enforcement off, the way a file written by an + // older build (or with the pragma disabled) can arrive on disk. + conn.execute_batch("PRAGMA foreign_keys = OFF;").unwrap(); + conn.execute( + "INSERT INTO identities \ + (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, NULL, ?3, 0)", + rusqlite::params![&[0x1Au8; 32][..], &[0x2Bu8; 32][..], vec![0u8; 4]], + ) + .unwrap(); + conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); + + // `integrity_check` on its own is satisfied by this file. + let structural: String = conn + .query_row("PRAGMA integrity_check", [], |r| r.get(0)) + .unwrap(); + assert_eq!(structural, "ok", "the file is structurally sound"); + + let err = run_integrity_check(&conn, |report| WalletStorageError::IntegrityCheckFailed { + report, + }) + .expect_err("a dangling foreign key must fail verification"); + match err { + WalletStorageError::IntegrityCheckFailed { report } => { + assert!( + report.contains("foreign_key_check") && report.contains("identities"), + "report must name the check and the offending table, got: {report}" + ); + } + other => panic!("expected IntegrityCheckFailed, got {other:?}"), + } + } + + /// `foreign_key_check` has no cap of its own — a missing parent makes + /// EVERY child row a violation at once. The report is allocated whole, + /// logged whole, and printed to an operator's terminal, so it must stay + /// bounded by the cap rather than by the extent of the damage. + #[test] + fn integrity_report_is_capped_on_a_flood_of_foreign_key_violations() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn.execute_batch("PRAGMA foreign_keys = OFF;").unwrap(); + let overflow = 37usize; + let planted = MAX_INTEGRITY_REPORT_LINES + overflow; + { + let tx = conn.transaction().unwrap(); + for i in 0..planted { + let mut identity_id = [0u8; 32]; + identity_id[..8].copy_from_slice(&(i as u64).to_le_bytes()); + tx.execute( + "INSERT INTO identities \ + (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, NULL, ?3, 0)", + rusqlite::params![&identity_id[..], &[0x2Bu8; 32][..], vec![0u8; 4]], + ) + .unwrap(); + } + tx.commit().unwrap(); + } + conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); + + let err = run_integrity_check(&conn, |report| WalletStorageError::IntegrityCheckFailed { + report, + }) + .expect_err("a flood of dangling foreign keys must still fail verification"); + match err { + WalletStorageError::IntegrityCheckFailed { report } => { + let lines: Vec<&str> = report.lines().collect(); + assert_eq!( + lines.len(), + MAX_INTEGRITY_REPORT_LINES + 1, + "report must be the cap plus one summary line, not one line per damaged row" + ); + assert!( + lines[MAX_INTEGRITY_REPORT_LINES] + .contains(&format!("and {overflow} further foreign-key violations")), + "the suppressed count must be exact, got: {}", + lines[MAX_INTEGRITY_REPORT_LINES] + ); + } + other => panic!("expected IntegrityCheckFailed, got {other:?}"), + } + } + + /// A clean migrated database passes both halves of the check. + #[test] + fn integrity_check_passes_a_clean_database() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + run_integrity_check(&conn, |report| WalletStorageError::IntegrityCheckFailed { + report, + }) + .expect("a freshly migrated database is both sound and consistent"); + } + #[test] fn manual_backup_filename_matches_regex() { let n = manual_backup_filename(); @@ -559,6 +830,168 @@ mod tests { assert_eq!(secs, 1767225600); } + /// `backup_timestamp` must extract the embedded timestamp (not fall + /// back to mtime) for every `BackupKind` shape, including ones with + /// inner `-`. Guards the `rsplit('-')` coupling against a future label + /// that shifts the trailing token. + #[test] + fn backup_timestamp_extracts_embedded_token_for_all_kinds() { + let want = parse_compact_timestamp("20260101T000000Z").unwrap(); + let real_wallet_id = hex::encode([0xABu8; 32]); + let names = [ + "wallet-20260101T000000Z.db".to_string(), + // Multiple `-` from the db stem and the from/to version segments. + "pre-migration-det-mainnet-1-to-2-20260101T000000Z.db".to_string(), + // 64 lowercase hex chars: hex::encode never emits `-`, so the + // timestamp stays the last `-`-delimited token. + format!("pre-delete-{real_wallet_id}-20260101T000000Z.db"), + "pre-restore-det-mainnet-20260101T000000Z.db".to_string(), + ]; + for name in names { + let got = backup_timestamp(Path::new(&name)); + assert_eq!( + got, + Some(want), + "backup_timestamp must parse the embedded token, not fall back to mtime, for {name}" + ); + } + } + + /// A label with a trailing non-timestamp segment must return `None` + /// (prune falls back to mtime) rather than misread a wrong token as a + /// valid time — a detectable regression if a future `BackupKind` + /// appends a `-`-bearing suffix after the timestamp. + #[test] + fn backup_timestamp_rejects_trailing_non_timestamp_segment() { + assert_eq!( + backup_timestamp(Path::new("pre-delete-20260101T000000Z-label.db")), + None, + "a trailing non-timestamp segment must not parse as a timestamp" + ); + } + + /// Two databases in one directory backed up within the same + /// second-resolution timestamp must still get distinct filenames — the + /// timestamp is pinned here so the collision is deterministic rather + /// than dependent on both calls landing in the same wall-clock second. + #[test] + fn auto_backup_filename_separates_sibling_dbs_sharing_a_timestamp() { + let ts = "20260101T000000Z"; + let mainnet = sanitize_db_stem(Path::new("/data/det-mainnet.sqlite")); + let testnet = sanitize_db_stem(Path::new("/data/det-testnet.sqlite")); + let cases = [ + ( + auto_backup_filename_at( + BackupKind::PreMigration { + db_stem: &mainnet, + from: 1, + to: 9, + }, + ts, + ), + auto_backup_filename_at( + BackupKind::PreMigration { + db_stem: &testnet, + from: 1, + to: 9, + }, + ts, + ), + ), + ( + auto_backup_filename_at(BackupKind::PreRestore { db_stem: &mainnet }, ts), + auto_backup_filename_at(BackupKind::PreRestore { db_stem: &testnet }, ts), + ), + ]; + for (first, second) in cases { + assert_ne!( + first, second, + "sibling databases must not share a backup filename" + ); + assert!(first.contains(&mainnet), "{first} must name its source DB"); + assert!( + second.contains(&testnet), + "{second} must name its source DB" + ); + } + } + + /// The embedded stem must not shift the trailing timestamp token that + /// `prune` reads back, nor break prefix-based backup recognition — + /// including when the stem itself contains `-`. + #[test] + fn auto_backup_filename_with_db_stem_stays_parseable() { + let ts = "20260101T000000Z"; + let want = parse_compact_timestamp(ts).unwrap(); + let stem = sanitize_db_stem(Path::new("/data/det-mainnet.sqlite")); + assert_eq!(stem, "det-mainnet", "`-` survives sanitization"); + for name in [ + auto_backup_filename_at( + BackupKind::PreMigration { + db_stem: &stem, + from: 1, + to: 9, + }, + ts, + ), + auto_backup_filename_at(BackupKind::PreRestore { db_stem: &stem }, ts), + ] { + let path = Path::new(&name); + assert!(is_backup_file(path), "{name} must stay a recognised backup"); + assert_eq!( + backup_timestamp(path), + Some(want), + "{name} must keep the timestamp as its last `-` token" + ); + } + } + + /// The stem is derived from a filesystem path, so it must never carry a + /// path separator, a `.` that could form `..`, or a non-ASCII byte into + /// the backup directory. + #[test] + fn sanitize_db_stem_maps_every_character_outside_the_allowlist() { + for (path, want) in [ + ("/data/det-mainnet.sqlite", "det-mainnet"), + ("/data/det_app.sqlite", "det_app"), + // A `.` inside the stem cannot survive to form a `..` component. + ("/data/a.b.db", "a_b"), + ("/data/a b.db", "a_b"), + // Separators of either flavour, and a leading-dot stem. + ("/data/a\\b.db", "a_b"), + ("/data/.hidden.db", "_hidden"), + ("/data/wället.db", "w_llet"), + ] { + assert_eq!(sanitize_db_stem(Path::new(path)), want, "stem of {path}"); + } + } + + /// Non-UTF-8 filename bytes are legal on Unix; they must degrade to `_` + /// rather than panic or leak raw bytes into the filename. + #[cfg(unix)] + #[test] + fn sanitize_db_stem_handles_non_utf8_bytes() { + use std::os::unix::ffi::OsStrExt; + let raw = std::ffi::OsStr::from_bytes(b"a\xffb.db"); + assert_eq!(sanitize_db_stem(Path::new(raw)), "a_b"); + } + + /// An absurdly long stem is truncated, and a path with no stem at all + /// still yields a usable, non-empty token. + #[test] + fn sanitize_db_stem_bounds_length_and_fills_in_missing_stems() { + let long = format!("/data/{}.db", "x".repeat(500)); + let stem = sanitize_db_stem(Path::new(&long)); + assert_eq!(stem.len(), MAX_DB_STEM_LEN, "stem must be bounded"); + for path in ["..", "/", ""] { + assert_eq!( + sanitize_db_stem(Path::new(path)), + FALLBACK_DB_STEM, + "{path} has no usable stem" + ); + } + } + #[test] fn is_backup_file_recognises_prefixes() { assert!(is_backup_file(Path::new("/tmp/wallet-20260101T000000Z.db"))); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs b/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs index 616c7a8e3f6..3cf835019e1 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs @@ -4,7 +4,9 @@ //! 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. +//! database connection: a caller that must validate an incoming +//! changeset against disk hands the probe to `store_checked` as a +//! closure, so the probe and the merge share one critical section. use std::collections::HashMap; use std::sync::Mutex; @@ -25,11 +27,40 @@ impl Buffer { } /// Merge a changeset into the buffer for `wallet_id`. + /// + /// Test-only convenience: every production write goes through + /// [`store_checked`](Self::store_checked), which the persister hands + /// the identity-slot probe. + #[cfg(test)] pub fn store( &self, wallet_id: WalletId, cs: PlatformWalletChangeSet, ) -> Result<(), WalletStorageError> { + self.store_checked(wallet_id, cs, |_, _| Ok(())) + } + + /// Merge a changeset into the buffer for `wallet_id`, but only if + /// `check` accepts it. + /// + /// `check` is handed the wallet's currently-buffered changeset (if + /// any) and the incoming one, and runs UNDER the buffer lock — no + /// other `store` for this wallet can slip between the check and the + /// merge. On `Err` the buffered changeset is left exactly as it was + /// and `cs` is dropped, so only the caller that made the offending + /// write pays for it. + pub fn store_checked( + &self, + wallet_id: WalletId, + cs: PlatformWalletChangeSet, + check: F, + ) -> Result<(), WalletStorageError> + where + F: FnOnce( + Option<&PlatformWalletChangeSet>, + &PlatformWalletChangeSet, + ) -> Result<(), WalletStorageError>, + { if cs.is_empty() { return Ok(()); } @@ -37,6 +68,7 @@ impl Buffer { .inner .lock() .map_err(|_| WalletStorageError::LockPoisoned)?; + check(guard.get(&wallet_id), &cs)?; guard.entry(wallet_id).or_default().merge(cs); Ok(()) } @@ -94,6 +126,17 @@ impl Buffer { ids.sort(); Ok(ids) } + + /// Discard every buffered changeset after the backing connection becomes + /// permanently unusable. + pub fn discard_all(&self) -> Result<(), WalletStorageError> { + let mut guard = self + .inner + .lock() + .map_err(|_| WalletStorageError::LockPoisoned)?; + guard.clear(); + Ok(()) + } } #[cfg(test)] @@ -136,6 +179,42 @@ mod tests { assert_eq!(core.last_processed_height, Some(10)); } + #[test] + fn store_checked_shows_the_check_what_is_already_buffered() { + let buf = Buffer::new(); + let w = [0xCCu8; 32]; + buf.store(w, cs_height(10, 10)).unwrap(); + + let seen = std::cell::Cell::new(None); + buf.store_checked(w, cs_height(20, 20), |buffered, incoming| { + seen.set(Some(( + buffered.and_then(|cs| cs.core.as_ref()?.synced_height), + incoming.core.as_ref().unwrap().synced_height, + ))); + Ok(()) + }) + .unwrap(); + + assert_eq!(seen.get(), Some((Some(10), Some(20)))); + } + + #[test] + fn store_checked_rejection_leaves_the_buffered_value_untouched() { + let buf = Buffer::new(); + let w = [0xDDu8; 32]; + buf.store(w, cs_height(10, 10)).unwrap(); + + let err = buf + .store_checked(w, cs_height(20, 20), |_, _| { + Err(WalletStorageError::LockPoisoned) + }) + .expect_err("the check refused the incoming changeset"); + + assert!(matches!(err, WalletStorageError::LockPoisoned)); + let kept = buf.take_for_flush(&w).unwrap().expect("value still staged"); + assert_eq!(kept.core.expect("core present").synced_height, Some(10)); + } + #[test] fn restore_into_empty_slot_inserts() { let buf = Buffer::new(); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/config.rs b/packages/rs-platform-wallet-storage/src/sqlite/config.rs index 1beb7c2c021..9efe0d264e6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/config.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/config.rs @@ -14,6 +14,80 @@ pub enum FlushMode { Immediate, } +/// How `load()` reacts to a recoverable inconsistency in persisted rows. +/// +/// The two policies are not symmetric: `Strict` is the safe default and +/// `Recovery` is a diagnostic escape hatch that reproduces the historical +/// best-effort behaviour verbatim. `Recovery` never tolerates anything +/// `Strict` would not also have reached — an unusable schema version or a +/// failed `PRAGMA integrity_check` still hard-error. +/// +/// # A per-row failure costs its wallet, not the file +/// +/// Every failure `load()` meets inside a wallet's own rehydration — an +/// undecodable script, an oversize blob, a row whose columns contradict its +/// payload — degrades THAT wallet. Under `Recovery` the wallet is dropped +/// whole, counted at [`LoadSite::WalletRehydration`], and named in +/// [`LoadDegradation::wallets_degraded`] alongside the kind of what stopped +/// it; every other wallet in the file still loads. Whole-wallet granularity +/// is what keeps `Strict`'s promise intact under `Recovery` too: a wallet is +/// never handed back half-formed, only entire or not at all. +/// +/// This does NOT relax the allocation guard. `blob::check_size` still +/// rejects an oversize blob on its stored LENGTH, before any buffer is +/// materialised, so the bytes are never read whatever the policy says; the +/// policy only decides what happens once the guard has already fired. Under +/// `Strict` the original error propagates unchanged — the boundary reports +/// the cause, never replaces it. +/// +/// [`LoadSite::WalletRehydration`]: crate::LoadSite::WalletRehydration +/// [`LoadDegradation::wallets_degraded`]: crate::LoadDegradation::wallets_degraded +/// +/// # Open-time gates are unconditional +/// +/// `open()` runs migrations, and migrating a structurally corrupt file +/// amplifies the damage, so the integrity check, schema-version gate, +/// foreign-key gate, schema-history probe, and wallet-identity check stay +/// hard in both policies. SQLite-level corruption reaching the decoders +/// yields arbitrary garbage rows that `Recovery` would then tolerate and +/// count, inverting the point of the feature. A database failing +/// `integrity_check` needs +/// [`restore_from`](crate::SqlitePersister::restore_from) or +/// `sqlite3 .recover`, not recovery mode. +/// +/// # Examples +/// +/// ```rust +/// use platform_wallet_storage::{LoadPolicy, SqlitePersisterConfig}; +/// +/// let config = SqlitePersisterConfig::new("/tmp/wallets.db") +/// .with_load_policy(LoadPolicy::Recovery); +/// assert_eq!(config.load_policy, LoadPolicy::Recovery); +/// ``` +// TODO(recovery-mode): no FFI entry point constructs SqlitePersister today; when +// one is added, plumb SqlitePersisterConfig::with_load_policy and expose +// last_load_degradation across the boundary. +// TODO(recovery-mode): Recovery has no human-facing surface. The maintenance +// CLI deliberately has no --recovery flag — none of its subcommands call +// load(), so the flag would be a no-op that additionally blocked migrate and +// prune. A `verify` subcommand (open Recovery, load, print the per-site +// counts, exit non-zero when degraded) is the shape that would fit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LoadPolicy { + /// Any inconsistency aborts the load. A corrupted wallet is never + /// handed to the caller half-formed. + #[default] + Strict, + /// Best-effort load: tolerable inconsistencies are logged and counted + /// instead of returned. The persister is **read-only** — every write + /// entry point returns + /// [`WalletStorageError::ReadOnlyRecoveryMode`](crate::WalletStorageError::ReadOnlyRecoveryMode) + /// so a degraded projection can never be written back over good rows. + /// See [`SqlitePersister::last_load_degradation`](crate::SqlitePersister::last_load_degradation). + /// Diagnostic / rescue only. + Recovery, +} + /// SQLite journal mode. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum JournalMode { @@ -40,11 +114,19 @@ impl JournalMode { } /// SQLite synchronous mode. +/// +/// `Normal` (the default, paired with WAL) is **app-crash durable**: a +/// committed write survives a process crash but NOT a power loss / OS +/// crash mid-checkpoint, where the last transactions in the WAL can be +/// lost. Choose `Full` for power-loss durability at the cost of an fsync +/// per commit. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Synchronous { Off, + /// WAL default: durable across application crash, not power loss. #[default] Normal, + /// fsync on every commit: durable across power loss / OS crash. Full, Extra, } @@ -77,6 +159,10 @@ pub struct SqlitePersisterConfig { /// API destructive operations then return /// [`WalletStorageError::AutoBackupDisabled`](crate::WalletStorageError::AutoBackupDisabled). pub auto_backup_dir: Option, + /// How `load()` reacts to a recoverable inconsistency. Defaults to + /// [`LoadPolicy::Strict`] — safety must not depend on the caller + /// passing a flag, including on the struct-literal construction path. + pub load_policy: LoadPolicy, } impl SqlitePersisterConfig { @@ -91,6 +177,7 @@ impl SqlitePersisterConfig { journal_mode: JournalMode::default(), synchronous: Synchronous::default(), auto_backup_dir: Some(auto_backup_dir), + load_policy: LoadPolicy::default(), } } @@ -100,6 +187,14 @@ impl SqlitePersisterConfig { self } + /// Override the load policy. [`LoadPolicy::Recovery`] additionally + /// makes the persister read-only and requires `auto_backup_dir` to be + /// set. + pub fn with_load_policy(mut self, policy: LoadPolicy) -> Self { + self.load_policy = policy; + 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; @@ -109,10 +204,8 @@ impl SqlitePersisterConfig { /// `/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. +/// Public so the CLI binary (a separate compilation unit) shares the same +/// resolution as `SqlitePersisterConfig::new`. pub fn default_auto_backup_dir(db_path: &Path) -> PathBuf { let parent = db_path .parent() @@ -133,6 +226,7 @@ mod tests { 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.load_policy, LoadPolicy::Strict); 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 index de8e182f208..9947eefa546 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs @@ -1,23 +1,102 @@ //! 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). +//! `PRAGMA foreign_keys` is per-connection, defaults to OFF on every open, +//! and silently no-ops on a SQLite built without FK support — so every +//! writer connection must enable it and read it back to prove it took. +//! All library opens route through [`open_conn`]; the CLI's read-only +//! `peek_schema_version` probe opens directly (no mutations, and +//! `open_conn` is `pub(crate)`, unreachable from the bin target). +use rusqlite::limits::Limit; use rusqlite::{Connection, OpenFlags}; use std::path::Path; use crate::sqlite::error::WalletStorageError; +/// Global per-connection BLOB / string length ceiling applied to every +/// connection opened by this crate via [`open_conn`]. +/// +/// Value: **2 × [`crate::SIZE_LIMIT_BYTES`]** (= 32 MiB), giving one stop +/// of headroom above the typed per-column cap so that per-column gates (which +/// fire at 16 MiB via [`check_size`](crate::sqlite::schema::blob::check_size)) +/// still take precedence on explicitly gated columns while this backstop caps +/// ALL other columns — `script`, `outpoint`, `wallet_id`, `txid`, +/// `identity_id`, etc. — that carry no individual `length()` pre-read gate. +/// SQLite's compile-time default is ~1 GiB per string/BLOB/row; this reduces +/// it to 32 MiB for every connection opened by this crate, blocking a +/// tampered wallet DB from forcing multi-hundred-MiB heap allocations on +/// ungated columns. +pub(crate) const SQLITE_MAX_BLOB_BYTES: i32 = (crate::SIZE_LIMIT_BYTES * 2) as i32; + +// Compile-time guard: the `as i32` cast above is lossless only while +// SIZE_LIMIT_BYTES ≤ i32::MAX / 2 (~1 GiB). Widening SIZE_LIMIT_BYTES +// beyond that would silently truncate the limit, turning the backstop into +// a no-op. This assertion makes such a change a compile error instead. +const _: () = assert!( + crate::SIZE_LIMIT_BYTES <= (i32::MAX as usize) / 2, + "SQLITE_MAX_BLOB_BYTES would overflow i32 — lower SIZE_LIMIT_BYTES or widen the limit type", +); + +/// Magic stamped into the SQLite header `application_id` (offset 68) by +/// `V008__rehydration_base_schema`. ASCII `"PLWT"` (Platform Wallet) +/// big-endian. A +/// refinery-versioned DB whose `application_id` does not equal this is a +/// foreign SQLite database, not a wallet-storage DB. +pub(crate) const APPLICATION_ID: i32 = 0x504C_5754; + +/// Read the header `application_id` and assert it equals +/// [`APPLICATION_ID`]. Returns [`WalletStorageError::NotAWalletDb`] on +/// mismatch. The caller decides WHEN to run this — `open()` runs it +/// pre-migration on a refinery-versioned DB; `restore_from` runs it on +/// the staged copy. A brand-new (unmigrated) DB reports `0` and is the +/// caller's responsibility to skip (V008 stamps the real value). +pub(crate) fn assert_wallet_application_id(conn: &Connection) -> Result<(), WalletStorageError> { + let found: i32 = conn.pragma_query_value(None, "application_id", |row| row.get(0))?; + if found != APPLICATION_ID { + return Err(WalletStorageError::NotAWalletDb { + expected: APPLICATION_ID, + found, + }); + } + Ok(()) +} + +/// SQLite's default `application_id` for a file nobody stamped. A database +/// created before `V008` carries this, so it cannot be told from a stamped +/// wallet database by the header alone. +const UNSTAMPED_APPLICATION_ID: i32 = 0; + +/// Accept a stamped wallet database, or a LEGACY one predating the stamp. +/// +/// `V008` introduced `PRAGMA application_id`, so every database created by a +/// `v4.2-dev` build reports `0` and [`assert_wallet_application_id`] alone +/// would refuse it as foreign — before refinery ever runs, and with no way for +/// the caller to tell that apart from a genuinely foreign file. +/// +/// An unstamped file is admitted only when its `refinery_schema_history` names +/// migrations this binary embeds, at the same versions. That is a positive +/// identification rather than a relaxation: a foreign SQLite database does not +/// carry our migration names, and one carrying no history at all is refused. +/// Callers that have NOT established schema history must keep using +/// [`assert_wallet_application_id`]. +pub(crate) fn assert_wallet_application_id_or_legacy( + conn: &Connection, +) -> Result<(), WalletStorageError> { + let found: i32 = conn.pragma_query_value(None, "application_id", |row| row.get(0))?; + if found == APPLICATION_ID { + return Ok(()); + } + if found == UNSTAMPED_APPLICATION_ID + && crate::sqlite::migrations::applied_history_matches_embedded(conn)? + { + return Ok(()); + } + Err(WalletStorageError::NotAWalletDb { + expected: APPLICATION_ID, + found, + }) +} + /// How the opened connection will be used. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Access { @@ -35,15 +114,29 @@ pub(crate) enum Access { /// 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` and performs no pragma. URI filename parsing -/// is deliberately not enabled: the crate never constructs `file:` URIs, -/// and leaving it off keeps a path from ever smuggling query parameters -/// (e.g. `?mode=rwc`) that could defeat the read-only intent. +/// `SQLITE_OPEN_READ_ONLY` and performs no pragma. URI-like filenames are +/// rejected and `SQLITE_OPEN_URI` is omitted so a path can't smuggle query +/// parameters (e.g. `?mode=rwc`) that defeat the read-only intent. pub(crate) fn open_conn(path: &Path, access: Access) -> Result { + // Bundled SQLite enables URI parsing globally, independently of open flags. + if path.as_os_str().as_encoded_bytes().starts_with(b"file:") { + return Err(rusqlite::Error::InvalidPath(path.to_path_buf()).into()); + } let conn = match access { - Access::ReadWrite => Connection::open(path)?, + Access::ReadWrite => Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?, Access::ReadOnly => Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?, }; + // Hard-cap every string/BLOB column at SQLITE_MAX_BLOB_BYTES (32 MiB). + // The per-column `check_size` gate still fires first on explicitly gated + // columns because its cap (16 MiB) is smaller. + // This backstop covers the rest without requiring individual `length()` + // pre-reads on every column in every reader. + conn.set_limit(Limit::SQLITE_LIMIT_LENGTH, SQLITE_MAX_BLOB_BYTES)?; if access == Access::ReadWrite { enforce_foreign_keys(&conn)?; } @@ -65,6 +158,18 @@ pub(crate) fn enforce_foreign_keys(conn: &Connection) -> Result<(), WalletStorag mod tests { use super::*; + #[test] + fn read_write_open_does_not_interpret_uri_filenames() { + let path = Path::new("file:probe.db?mode=ro"); + let result = open_conn(path, Access::ReadWrite); + + assert!(matches!( + result, + Err(WalletStorageError::Sqlite(rusqlite::Error::InvalidPath(found))) + if found == path + )); + } + /// A read-write open enables FK and the read-back confirms it — the /// assertion path that guards against a silently no-op pragma. #[test] @@ -77,10 +182,8 @@ mod tests { 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". + /// The bundled build can't produce a FK-less SQLite, so assert the + /// read-back error variant at least renders its intended message. #[test] fn foreign_keys_not_enforced_variant_renders() { let err = WalletStorageError::ForeignKeysNotEnforced; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index c1767d5b7e3..1051e4a4b27 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -1,18 +1,13 @@ //! 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. +//! Variants carry the upstream error via `#[source]`/`#[from]`, never a +//! stringified copy; the `#[error("...")]` attribute provides `Display`. //! -//! 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. +//! At the `PlatformWalletPersistence` boundary this converts into +//! `PersistenceError`: `LockPoisoned` keeps its dedicated variant, and +//! everything else flows through `Backend { kind, source }` where `kind` +//! comes from [`WalletStorageError::persistence_kind`] and `source` +//! preserves the typed error for `Error::source()` walking. use std::path::PathBuf; @@ -20,6 +15,10 @@ use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; use crate::sqlite::util::safe_cast::SafeCastTarget; +fn optional_height_display(height: Option) -> String { + height.map_or_else(|| "unconfirmed".to_owned(), |height| height.to_string()) +} + /// Which automatic-backup operation was attempted when the /// configured backup directory was missing or otherwise unwritable. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] @@ -98,8 +97,22 @@ pub enum WalletStorageError { source: std::io::Error, }, + /// A database ancestor is writable without the sticky bit or is owned by + /// neither the current user nor root, allowing replacement despite `0600`. + /// + /// Names the offending ancestor rather than the database's own parent: the + /// walk runs to `/`, and a nine-component path leaves the user nothing to + /// act on otherwise. + #[error("{}", crate::parent_permissions::insecure_ancestor_message("database", .ancestor, .reason))] + InsecureParentDir { + /// The ancestor that was refused, not the database's parent. + ancestor: PathBuf, + /// Which of the two conditions fired; they need different remediations. + reason: crate::parent_permissions::InsecureAncestor, + }, + /// `delete_wallet` (or another wallet-id-keyed operation) was - /// called with an id that has no matching `wallet_metadata` row. + /// called with an id that has no matching `wallets` row. #[error("wallet not found: {}", hex::encode(wallet_id))] WalletNotFound { wallet_id: [u8; 32] }, @@ -133,10 +146,14 @@ pub enum WalletStorageError { 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 stored identifier column did not contain exactly 32 bytes. + #[error("invalid id length in {column}: expected 32 bytes, got {actual}")] + InvalidWalletIdLength { + /// Schema-qualified column containing the malformed identifier. + column: &'static str, + /// Actual byte length read from the column. + actual: usize, + }, /// A `SqlitePersisterConfig` field carries an unsupported value /// (e.g. `synchronous = Off`). The `reason` is a compile-time @@ -180,6 +197,17 @@ pub enum WalletStorageError { source: dashcore::consensus::encode::Error, }, + /// A stored `script` blob parsed as bytes but not as a + /// [`dashcore::Address`]. Carries the upstream + /// [`dashcore::address::Error`] (`UnrecognizedScript`, + /// `ExcessiveScriptSize`, `NetworkValidation`, …) so *why* the script + /// isn't an address survives instead of collapsing to a static reason. + #[error("stored script is not a valid address")] + AddressDecode { + #[source] + source: dashcore::address::Error, + }, + /// The CLI's `backup` subcommand refuses to overwrite an existing /// destination file. #[error("backup destination already exists: {}", path.display())] @@ -192,6 +220,25 @@ pub enum WalletStorageError { #[error("identity key entry fields disagree with its map key / wallet scope")] IdentityKeyEntryMismatch, + /// An `identity_keys` write named an identity the flush-scoped wallet does + /// not own. The compound FK to `identities(wallet_id, identity_id)` rejects + /// it: a key filed under a non-owning wallet is unreadable by every + /// per-wallet loader and surfaces later as a fatal orphan. + #[error( + "identity key rejected: wallet {} has no owning identities row for identity {}", + hex::encode(wallet_id), + hex::encode(identity_id) + )] + IdentityKeyWalletMismatch { + wallet_id: [u8; 32], + identity_id: [u8; 32], + /// The driver's own FK violation, kept walkable so callers can + /// still reach the raw constraint text. Boxed so this variant + /// doesn't inflate every `Result` in the crate. + #[source] + source: Box, + }, + /// An `identities` upsert entry's `id` disagreed with the map key the /// `identity_id` column is bound from — persisting it would leave the /// typed id column and the serialized blob naming different @@ -199,12 +246,138 @@ pub enum WalletStorageError { #[error("identity entry id disagrees with its map key")] IdentityEntryIdMismatch, + /// An `identity_scan_states` row claims a complete scan while + /// `identity_scan_failed_indices` still holds unanswered indices for it. + /// No fold can produce that pair, so the row contradicts itself. + #[error( + "identity scan verdict for wallet {} claims completeness over {failed_indices} \ + unanswered index(es)", + hex::encode(wallet_id) + )] + IdentityScanStateContradiction { + /// The wallet whose verdict contradicts itself. + wallet_id: platform_wallet::wallet::platform_wallet::WalletId, + /// How many unanswered indices sit beside the completeness claim. + failed_indices: usize, + }, + + /// Two different identities claimed one wallet's derivation slot. + /// `identity_index` is an HD path component, so `(wallet_id, + /// identity_index)` names exactly one identity; a second claim is a + /// contradiction rather than a competition, and is refused instead + /// of orphaning the displaced identity's keys at the next load. + #[error( + "identity index conflict: index {identity_index} of wallet {} is held by identity {}, cannot assign it to {}", + hex::encode(wallet_id), + hex::encode(existing), + hex::encode(incoming) + )] + IdentityIndexConflict { + wallet_id: [u8; 32], + identity_index: u32, + existing: [u8; 32], + incoming: [u8; 32], + }, + + /// A wallet-less identity carried a derivation index. Out-of-wallet + /// identities are keyed by identity id alone and have no derivation + /// context, so an index on one is state that can never be honoured. + #[error( + "wallet-less identity {} carries derivation index {identity_index}", + hex::encode(identity_id) + )] + WalletlessIdentityIndex { + identity_id: [u8; 32], + identity_index: u32, + }, + + /// A rehydration merge (`load_prekeyed`) found an `identity_keys` / + /// `contacts` entry whose owner identity is neither loaded nor + /// tombstoned for this wallet — an orphaned row a logical delete does + /// not explain. Hard-error rather than silently drop live key / contact + /// state; only a known-tombstoned owner's orphaned rows are safe to skip. + #[error( + "rehydration merge found an orphaned entry: owner {} is neither loaded nor tombstoned", + hex::encode(owner) + )] + OrphanedIdentityEntry { owner: [u8; 32] }, + + /// One wallet could not be rehydrated by `load()`'s per-wallet loop. + /// + /// Raised only at the loop's isolation boundary, so a failure that + /// belongs to one wallet is attributable to it instead of ending the + /// whole load. The cause is carried as text rather than as a source: the + /// boundary hands the ORIGINAL error back under `LoadPolicy::Strict`, and + /// this variant exists for the degraded path, where the load survives. + #[error("wallet {} could not be rehydrated: {cause}", hex::encode(wallet_id))] + WalletRehydrationFailed { wallet_id: [u8; 32], cause: String }, + + /// An `account_registrations` row's typed `(account_type, account_index)` + /// columns disagreed with the decoded `AccountRegistrationEntry` blob. + /// Rejected at decode time so the manifest oracle never hands back an + /// entry that names a different account type or index than the indexed + /// columns it was selected by. + #[error( + "account_registrations entry fields disagree with typed columns \ + (typed columns vs blob account_type or account_index mismatch)" + )] + AccountRegistrationEntryMismatch, + + /// A provider key-material entry uses an incompatible account-registration + /// path, pairs an account type with the wrong key curve, or has persisted + /// typed columns that contradict its decoded `ProviderKeyAccountEntry`. + /// The guards reject it on write or decode so cross-curve-confused data + /// never enters a wallet. + #[error( + "provider key-material account was submitted through an incompatible \ + account-registration path, paired with the wrong key curve, or has \ + corrupted persisted data" + )] + ProviderKeyAccountEntryMismatch, + + /// An incoming provider key-material entry conflicts with another entry in + /// the flush or with the account already persisted under the same label. + /// The store cannot tell which extended public key is correct, so it fails + /// closed instead of letting write order pick a winner. + #[error( + "conflicting provider key account for {account_type} \ + (extended public keys differ)" + )] + ProviderKeyAccountConflict { account_type: &'static str }, + + /// An incoming typed address-pool row conflicts with key material already + /// persisted at the same account, pool, and address index. + #[error( + "conflicting typed pool key for {account_type} at address index {address_index} \ + (public key or key type differs)" + )] + TypedPoolKeyConflict { + account_type: &'static str, + address_index: u32, + }, + + /// Account was rejected by the wallet manager (e.g. `account_type` is unknown, or + /// `account_index` is out of range). The `cause` is a static string describing the reason. + #[error("account rejected by wallet manager: {cause}")] + AccountRejected { cause: String }, + + /// An `account_registrations` row is missing for a given `(account_type, account_index)`. + #[error( + "required account information is missing for wallet {}", + hex::encode(wallet_id) + )] + MissingAccount { wallet_id: [u8; 32] }, + + /// Account record is invalid + #[error("account record is corrupted or invalid: {e}")] + AccountRecordInvalid { + #[source] + e: key_wallet::error::Error, + }, + /// 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. + /// disagreed with the lifecycle blob's. 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}, \ @@ -217,26 +390,42 @@ pub enum WalletStorageError { 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. + /// An `asset_locks` row's typed status disagreed with its lifecycle blob. + #[error( + "asset lock {outpoint} status disagrees with lifecycle blob \ + (typed status={typed_status}, blob status={blob_status})" + )] + AssetLockStatusMismatch { + outpoint: String, + typed_status: String, + blob_status: String, + }, + + /// A `core_transactions` row's typed txid or height disagreed with its + /// decoded transaction record. + #[error( + "core transaction entry fields disagree with typed columns \ + (typed txid={typed_txid}, blob txid={blob_txid}, \ + typed height={}, blob height={})", + optional_height_display(*.typed_height), + optional_height_display(*.blob_height) + )] + CoreTransactionEntryMismatch { + typed_txid: String, + blob_txid: String, + typed_height: Option, + blob_height: Option, + }, + + /// A blob exceeded the decode allocation cap (default 16 MiB). + /// Separate from [`Self::BlobDecode`] so operators can distinguish an + /// oversize blob from a structural decode failure. #[error("blob exceeded decode size limit ({len_bytes} bytes > {limit_bytes} byte cap)")] BlobTooLarge { len_bytes: usize, limit_bytes: usize, }, - /// An unspent UTXO named an address absent from - /// `core_derived_addresses`, so its owning account index can't be - /// resolved. Persisting it would mis-file live funds under account - /// 0 with no path back to the real account, so the write is refused. - /// Spent-only placeholder rows tolerate a missing mapping (they're - /// excluded from the unspent set) and do not raise this. - #[error("unspent utxo address {address} is not in core_derived_addresses")] - UtxoAddressNotDerived { address: String }, - /// `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 @@ -244,6 +433,50 @@ pub enum WalletStorageError { #[error("SQLite foreign-key enforcement could not be enabled on this connection")] ForeignKeysNotEnforced, + /// The requested `journal_mode` read back as a different mode — + /// SQLite silently fell back (e.g. WAL→DELETE on some FUSE mounts). + /// With `synchronous=NORMAL` that risks corruption on power loss, so + /// open hard-errors instead of running downgraded. + #[error("journal_mode {requested} could not be applied (SQLite reports {actual})")] + JournalModeNotApplied { + requested: &'static str, + actual: String, + }, + + /// `PRAGMA secure_delete` was issued on open but read back as `0` (off). + /// Without it SQLite leaves deleted row content readable in freed pages, + /// and `Backup` copies those pages into every later snapshot, so a wallet + /// the user deleted would keep propagating. Hard-error at open rather than + /// running with an at-rest guarantee the crate documents and does not have. + #[error("PRAGMA secure_delete could not be enabled on this connection (reports {actual})")] + SecureDeleteNotApplied { actual: i64 }, + + /// A pre-existing / restored DB passed `integrity_check` but its + /// `refinery_schema_history` carries a malformed row (non-RFC3339 + /// `applied_on` or non-numeric `checksum`). Probed BEFORE refinery + /// runs so a foreign or corrupted-but-integrity-valid input returns + /// a typed error instead of refinery panicking on the parse. + #[error("refinery_schema_history is malformed: {reason}")] + SchemaHistoryMalformed { reason: &'static str }, + + /// A restore source / opened DB carries a `refinery_schema_history` + /// (so it is refinery-versioned) but its `application_id` header does + /// not match the wallet-storage magic — it is a foreign SQLite DB, + /// not a wallet database. Rejected before it can be persisted over + /// the live wallet DB or migrated in place. + #[error( + "not a platform-wallet-storage database: application_id {found:#010x} != expected {expected:#010x}" + )] + NotAWalletDb { expected: i32, found: i32 }, + + /// A second [`SqlitePersister`](crate::SqlitePersister) `open()` on a + /// path already open in THIS process. Each handle has its own + /// `Mutex` and write buffer, so buffered writes on one are + /// invisible to the other — silent state divergence. Refused until the + /// first persister drops. + #[error("a SqlitePersister is already open on {} in this process", path.display())] + AlreadyOpen { path: PathBuf }, + /// 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}")] @@ -253,20 +486,11 @@ pub enum WalletStorageError { target: SafeCastTarget, }, - /// 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. + /// Flush failed transiently (e.g. `SQLITE_BUSY` / `SQLITE_LOCKED`) for + /// `wallet_id`. The buffered changeset is restored, so the next + /// `flush(wallet_id)` retries it merged with anything stored in + /// between. Use **exponential backoff** — tight-looping turns lock + /// contention into a CPU spin that starves the lock holder. #[error( "FlushRetryable: flush failed transiently for wallet {}; buffer preserved for retry", hex::encode(wallet_id) @@ -276,6 +500,165 @@ pub enum WalletStorageError { #[source] source: rusqlite::Error, }, + + /// Rehydration's discovery probes don't mirror the real account's + /// address pools 1:1 (`probes.len() != pools.len()`) — a structural + /// invariant break, not user-reachable. Fail-closed rather than apply a + /// probe's discovered depth to the wrong pool by position. + #[error("rehydration pool count mismatch: expected {expected} probe pool(s), found {found}")] + RehydrationPoolMismatch { expected: usize, found: usize }, + + /// Rehydration's discovery probes mirror the real account's pools by + /// count but not by chain identity at `position` — applying the + /// probe's discovered depth here would misattribute derivation to the + /// wrong pool. + #[error( + "rehydration pool type mismatch at position {position}: expected {expected:?}, found {found:?}" + )] + RehydrationPoolTypeMismatch { + position: usize, + expected: key_wallet::managed_account::address_pool::AddressPoolType, + found: key_wallet::managed_account::address_pool::AddressPoolType, + }, + + /// A write was attempted on a persister opened with + /// [`LoadPolicy::Recovery`](crate::LoadPolicy). Recovery is read-only + /// **unconditionally, by policy** — `ensure_writable` gates on the + /// configured policy, not on whether the load actually tolerated + /// anything, because writing back a load that turned out clean would + /// be safe but writing back one that degraded would overwrite good + /// rows with the tolerated view of them, and nothing at the write + /// call site can tell the two apart. Use + /// [`SqlitePersister::last_load_degradation`](crate::SqlitePersister::last_load_degradation) + /// or + /// [`SqlitePersister::is_degraded`](crate::SqlitePersister::is_degraded) + /// to find out whether this persister's last load actually needs + /// repair before assuming so. `operation` names the blocked entry + /// point. + #[error( + "`{operation}` is blocked: the persister is open in recovery mode, which is read-only by \ + policy regardless of whether the last load tolerated anything — check \ + `last_load_degradation()` (or `is_degraded()`) to see whether a repair is actually \ + needed, then reopen under the strict load policy to write again" + )] + ReadOnlyRecoveryMode { operation: &'static str }, + + /// A restored address could not be derived into its pool at the + /// resolved slot, so it stays unmarked and can be re-issued as a fresh + /// receive address (address-reuse privacy leak). `index` is the slot + /// the discovery probe resolved from this account's own xpub, which + /// the pool then failed to produce an address for. + #[error( + "a restored address at derivation index {index} could not be put back into its \ + address pool, so it may be handed out again as a fresh receive address" + )] + RehydrationEnsureDerivedFailed { index: u32 }, + + /// A pool's gap-limit refill would derive more addresses than + /// rehydration will spend on one pool, so the window stays short and a + /// previously-used address inside it can be re-issued as fresh. Costed + /// before the refill runs, so nothing is allocated on the way out. + #[error( + "refilling an address pool to derivation index {refill_target}, which already holds \ + {already_generated} address(es), implies {implied} new addresses, over the {cap} \ + rehydration cap; the pool's window stays short, so a previously-used address in it \ + may be handed out again as fresh" + )] + RehydrationGapLimitRefillTooLarge { + refill_target: u32, + already_generated: u32, + implied: u32, + cap: u32, + }, + + /// A pool's persisted state implies a gap-limit refill target that is + /// not a derivable non-hardened child index — either the target + /// over/underflows `u32`, or it lands at or past the BIP-32 + /// normal-child ceiling of `2^31`. Refused before the refill runs: + /// upstream computes the same target with raw arithmetic and would + /// panic on it, which no load policy can tolerate. + #[error( + "an address pool with highest used index {highest_used:?} and gap limit {gap_limit} \ + implies a refill target that is not a derivable address index; the pool's window \ + stays short, so a previously-used address in it may be handed out again as fresh" + )] + RehydrationGapLimitTargetOutOfRange { + highest_used: Option, + gap_limit: u32, + }, + + /// The upstream gap-limit refill itself failed, leaving the pool's + /// window short — a previously-used address inside it can be re-issued + /// as fresh. + #[error( + "an address pool's gap window could not be refilled, so a previously-used address \ + in it may be handed out again as a fresh receive address" + )] + RehydrationGapLimitFailed { + #[source] + source: key_wallet::error::Error, + }, + + /// One used address resolves to two different owning accounts — + /// `core_address_pool` says one, `core_utxos` another. The store cannot + /// tell which account may re-issue the address, so it fails closed + /// rather than letting one source silently win. + #[error( + "used address {address} resolves to different owning accounts \ + (core_address_pool={pool_owner}, core_utxos={utxo_owner}) — neither is trusted, so \ + repair whichever row is wrong before loading again" + )] + UsedAddressOwnerConflict { + address: String, + pool_owner: String, + utxo_owner: String, + }, + + /// An identity owned by no wallet carries a registration index — a + /// position WITHIN a wallet, so the row contradicts itself. + #[error( + "unowned identity {} carries wallet registration index {identity_index}, a position \ + within a wallet it belongs to none of — clear the stale index to load it strictly", + hex::encode(identity_id) + )] + UnownedIdentityHasRegistrationIndex { + identity_id: [u8; 32], + identity_index: u32, + }, + + /// A `core_utxos` write carried an empty `script`. + /// + /// `load()` turns every stored script back into an address, so an empty + /// one leaves a row that rejects the load of the entire database file — + /// the shape migration V015 had to purge. Refused at the producer, where + /// the write can still be reported, rather than at the reader, where the + /// wallet is already un-loadable. + #[error("refusing to persist a core_utxos row for {outpoint} with an empty script")] + EmptyUtxoScript { outpoint: dashcore::OutPoint }, + + /// A `core_address_pool` write carried an empty `script`. + /// + /// `load()` turns every stored pool script back into an address, so an + /// empty one degrades — and under a strict policy fails — the load of + /// the wallet that owns it. Refused at the producer, where the write can + /// still be reported, rather than at the reader, where the row is + /// already persisted. + #[error( + "refusing to persist a core_address_pool row for {account_type} at address index \ + {address_index} with an empty script" + )] + EmptyPoolAddressScript { + account_type: &'static str, + address_index: u32, + }, + + /// The configured database path is a symbolic link. + /// + /// Opening it would follow the link, sending both the SQLite writes and + /// the owner-only chmod to the link's target. The path must name the + /// database file itself. + #[error("database path is a symlink: {}", path.display())] + DatabasePathIsSymlink { path: PathBuf }, } impl From for PersistenceError { @@ -291,31 +674,21 @@ impl From for PersistenceError { } impl WalletStorageError { - /// Construct a typed `BlobDecode` error from a static reason. - /// Used by schema modules that hit a structural decode error - /// (e.g. a 32-byte id column with the wrong length, or trailing - /// bytes after a payload). + /// Construct a `BlobDecode` error from a static reason. Used by schema + /// modules on a structural decode error (wrong-length id, trailing + /// 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: - /// - `DatabaseBusy` / `DatabaseLocked`: contention. - /// - `DiskFull`: operator clears disk space. - /// - `SystemIoFailure`: kernel-level I/O blip (NFS, raid rebuild). - /// - `OutOfMemory`: transient memory pressure. + /// `true` when the failure is safe to retry — the caller should + /// preserve in-flight state and call again. Transient codes are the + /// recoverable environmental ones: `DatabaseBusy`/`DatabaseLocked` + /// (contention), `DiskFull`, `SystemIoFailure`, `OutOfMemory`. /// - /// 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. + /// The OUTER match is intentionally wildcard-free so a future variant + /// forces explicit classification here; the INNER `ErrorCode` match + /// needs a wildcard because that enum is upstream `#[non_exhaustive]`. pub fn is_transient(&self) -> bool { use rusqlite::ErrorCode; match self { @@ -341,15 +714,9 @@ impl WalletStorageError { | Self::SchemaVersionUnsupported { .. } | Self::AutoBackupDisabled { .. } | Self::AutoBackupDirUnwritable { .. } + | Self::InsecureParentDir { .. } | Self::WalletNotFound { .. } | Self::WalletIdMismatch { .. } - // TODO(qa): `LockPoisoned` is classified as fatal here, but - // the end-to-end mutex-poison flow has no automated test (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 { .. } @@ -360,30 +727,57 @@ impl WalletStorageError { | Self::BlobDecode { .. } | Self::HashDecode { .. } | Self::ConsensusCodec { .. } + | Self::AddressDecode { .. } | Self::BackupDestinationExists { .. } | Self::ForeignKeysNotEnforced + | Self::JournalModeNotApplied { .. } + | Self::SecureDeleteNotApplied { .. } + | Self::SchemaHistoryMalformed { .. } + | Self::NotAWalletDb { .. } + | Self::AlreadyOpen { .. } | Self::IdentityKeyEntryMismatch + | Self::IdentityKeyWalletMismatch { .. } | Self::IdentityEntryIdMismatch + | Self::IdentityScanStateContradiction { .. } + | Self::IdentityIndexConflict { .. } + | Self::WalletlessIdentityIndex { .. } + | Self::OrphanedIdentityEntry { .. } + | Self::WalletRehydrationFailed { .. } + | Self::AccountRegistrationEntryMismatch + | Self::ProviderKeyAccountEntryMismatch + | Self::ProviderKeyAccountConflict { .. } + | Self::TypedPoolKeyConflict { .. } + | Self::AccountRecordInvalid { .. } + | Self::MissingAccount { .. } + | Self::AccountRejected { .. } | Self::AssetLockEntryMismatch { .. } + | Self::AssetLockStatusMismatch { .. } + | Self::CoreTransactionEntryMismatch { .. } | Self::BlobTooLarge { .. } - | Self::UtxoAddressNotDerived { .. } - | Self::IntegerOverflow { .. } => false, + | Self::IntegerOverflow { .. } + | Self::RehydrationPoolMismatch { .. } + | Self::RehydrationPoolTypeMismatch { .. } + | Self::ReadOnlyRecoveryMode { .. } + | Self::RehydrationEnsureDerivedFailed { .. } + | Self::RehydrationGapLimitRefillTooLarge { .. } + | Self::RehydrationGapLimitTargetOutOfRange { .. } + | Self::RehydrationGapLimitFailed { .. } + | Self::UsedAddressOwnerConflict { .. } + | Self::UnownedIdentityHasRegistrationIndex { .. } + | Self::EmptyUtxoScript { .. } + | Self::EmptyPoolAddressScript { .. } + | Self::DatabasePathIsSymlink { .. } => false, } } - /// Trait-boundary classification for the - /// [`PersistenceError::Backend`] kind field. Three classes: + /// Trait-boundary classification for [`PersistenceError::Backend`]: /// - /// - [`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::Transient`] — [`Self::is_transient`] true; caller MAY retry. + /// - [`PersistenceErrorKind::Constraint`] — SQL constraint/FK/CHECK violation; caller bug. /// - [`PersistenceErrorKind::Fatal`] — everything else. /// - /// [`Self::LockPoisoned`] is handled by the `From` impl directly - /// (it maps to [`PersistenceError::LockPoisoned`] rather than - /// flowing through `Backend`). + /// [`Self::LockPoisoned`] never reaches here; the `From` impl maps it + /// straight to [`PersistenceError::LockPoisoned`]. pub fn persistence_kind(&self) -> PersistenceErrorKind { use rusqlite::ErrorCode; if self.is_transient() { @@ -395,11 +789,88 @@ impl WalletStorageError { { 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, + // Uniqueness of `(wallet_id, identity_index)` is enforced in + // Rust, not by a SQL constraint, so it has to be classified + // here by hand — it is a caller-data violation all the same. + Self::IdentityIndexConflict { .. } | Self::WalletlessIdentityIndex { .. } => { + PersistenceErrorKind::Constraint + } + // Typed re-mapping of an FK violation — same class as the raw + // `ConstraintViolation` above, so it reports the same kind. + Self::IdentityKeyWalletMismatch { .. } => 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. + // + // Wildcard-free like `is_transient` above: a new variant must fail + // to compile here and force a decision, rather than inheriting + // `Fatal` — which is the wrong answer for every caller-data fault, + // as the hand-classified `Constraint` arms above attest. The + // transient variants are unreachable past the early return but + // still have to be named for exhaustiveness. The list is long; that + // is the cost of the guarantee. + Self::Sqlite(_) + | Self::FlushRetryable { .. } + | Self::Io(_) + | Self::Migration(_) + | Self::IntegrityCheckFailed { .. } + | Self::IntegrityCheckRunFailed { .. } + | Self::SourceOpenFailed { .. } + | Self::SchemaHistoryMissing + | Self::SchemaVersionUnsupported { .. } + | Self::AutoBackupDisabled { .. } + | Self::AutoBackupDirUnwritable { .. } + | Self::InsecureParentDir { .. } + | Self::WalletNotFound { .. } + | Self::WalletIdMismatch { .. } + | Self::LockPoisoned + | Self::RestoreDestinationLocked + | Self::InvalidWalletIdHex { .. } + | Self::InvalidWalletIdLength { .. } + | Self::ConfigInvalid { .. } + | Self::BincodeEncode { .. } + | Self::BincodeDecode { .. } + | Self::BlobDecode { .. } + | Self::HashDecode { .. } + | Self::ConsensusCodec { .. } + | Self::AddressDecode { .. } + | Self::BackupDestinationExists { .. } + | Self::ForeignKeysNotEnforced + | Self::JournalModeNotApplied { .. } + | Self::SecureDeleteNotApplied { .. } + | Self::SchemaHistoryMalformed { .. } + | Self::NotAWalletDb { .. } + | Self::AlreadyOpen { .. } + | Self::IdentityKeyEntryMismatch + | Self::IdentityEntryIdMismatch + | Self::IdentityScanStateContradiction { .. } + | Self::OrphanedIdentityEntry { .. } + | Self::WalletRehydrationFailed { .. } + | Self::AccountRegistrationEntryMismatch + | Self::ProviderKeyAccountEntryMismatch + | Self::ProviderKeyAccountConflict { .. } + | Self::TypedPoolKeyConflict { .. } + | Self::AccountRecordInvalid { .. } + | Self::MissingAccount { .. } + | Self::AccountRejected { .. } + | Self::AssetLockEntryMismatch { .. } + | Self::AssetLockStatusMismatch { .. } + | Self::CoreTransactionEntryMismatch { .. } + | Self::BlobTooLarge { .. } + | Self::IntegerOverflow { .. } + | Self::RehydrationPoolMismatch { .. } + | Self::RehydrationPoolTypeMismatch { .. } + | Self::ReadOnlyRecoveryMode { .. } + | Self::RehydrationEnsureDerivedFailed { .. } + | Self::RehydrationGapLimitRefillTooLarge { .. } + | Self::RehydrationGapLimitTargetOutOfRange { .. } + | Self::RehydrationGapLimitFailed { .. } + | Self::UsedAddressOwnerConflict { .. } + | Self::UnownedIdentityHasRegistrationIndex { .. } + | Self::EmptyUtxoScript { .. } + | Self::EmptyPoolAddressScript { .. } + | Self::DatabasePathIsSymlink { .. } => PersistenceErrorKind::Fatal, } } @@ -428,6 +899,7 @@ impl WalletStorageError { Self::SchemaVersionUnsupported { .. } => "schema_version_unsupported", Self::AutoBackupDisabled { .. } => "auto_backup_disabled", Self::AutoBackupDirUnwritable { .. } => "auto_backup_dir_unwritable", + Self::InsecureParentDir { .. } => "insecure_parent_dir", Self::WalletNotFound { .. } => "wallet_not_found", Self::WalletIdMismatch { .. } => "wallet_id_mismatch", Self::LockPoisoned => "lock_poisoned", @@ -440,14 +912,52 @@ impl WalletStorageError { Self::BlobDecode { .. } => "blob_decode", Self::HashDecode { .. } => "hash_decode", Self::ConsensusCodec { .. } => "consensus_codec", + Self::AddressDecode { .. } => "address_decode", + Self::WalletRehydrationFailed { .. } => "wallet_rehydration_failed", Self::BackupDestinationExists { .. } => "backup_destination_exists", Self::ForeignKeysNotEnforced => "foreign_keys_not_enforced", + Self::JournalModeNotApplied { .. } => "journal_mode_not_applied", + Self::SecureDeleteNotApplied { .. } => "secure_delete_not_applied", + Self::SchemaHistoryMalformed { .. } => "schema_history_malformed", + Self::NotAWalletDb { .. } => "not_a_wallet_db", + Self::AlreadyOpen { .. } => "already_open", Self::IdentityKeyEntryMismatch => "identity_key_entry_mismatch", + Self::IdentityKeyWalletMismatch { .. } => "identity_key_wallet_mismatch", Self::IdentityEntryIdMismatch => "identity_entry_id_mismatch", + Self::IdentityScanStateContradiction { .. } => "identity_scan_state_contradiction", + Self::IdentityIndexConflict { .. } => "identity_index_conflict", + Self::WalletlessIdentityIndex { .. } => "walletless_identity_index", + Self::OrphanedIdentityEntry { .. } => "orphaned_identity_entry", + Self::AccountRecordInvalid { .. } => "account_record_invalid", + Self::MissingAccount { .. } => "missing_account_registration_entry", + Self::AccountRejected { .. } => "account_rejected", + Self::AccountRegistrationEntryMismatch => "account_registration_entry_mismatch", + Self::ProviderKeyAccountEntryMismatch => "provider_key_account_entry_mismatch", + Self::ProviderKeyAccountConflict { .. } => "provider_key_account_conflict", + Self::TypedPoolKeyConflict { .. } => "typed_pool_key_conflict", Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", + Self::AssetLockStatusMismatch { .. } => "asset_lock_status_mismatch", + Self::CoreTransactionEntryMismatch { .. } => "core_transaction_entry_mismatch", Self::BlobTooLarge { .. } => "blob_too_large", - Self::UtxoAddressNotDerived { .. } => "utxo_address_not_derived", Self::IntegerOverflow { .. } => "integer_overflow", + Self::RehydrationPoolMismatch { .. } => "rehydration_pool_mismatch", + Self::RehydrationPoolTypeMismatch { .. } => "rehydration_pool_type_mismatch", + Self::ReadOnlyRecoveryMode { .. } => "read_only_recovery_mode", + Self::RehydrationEnsureDerivedFailed { .. } => "rehydration_ensure_derived_failed", + Self::RehydrationGapLimitRefillTooLarge { .. } => { + "rehydration_gap_limit_refill_too_large" + } + Self::RehydrationGapLimitTargetOutOfRange { .. } => { + "rehydration_gap_limit_target_out_of_range" + } + Self::RehydrationGapLimitFailed { .. } => "rehydration_gap_limit_failed", + Self::UsedAddressOwnerConflict { .. } => "used_address_owner_conflict", + Self::UnownedIdentityHasRegistrationIndex { .. } => { + "unowned_identity_has_registration_index" + } + Self::EmptyUtxoScript { .. } => "empty_utxo_script", + Self::EmptyPoolAddressScript { .. } => "empty_pool_address_script", + Self::DatabasePathIsSymlink { .. } => "database_path_is_symlink", } } } @@ -475,3 +985,46 @@ impl From for WalletStorageError { Self::ConsensusCodec { source } } } + +impl From for WalletStorageError { + fn from(source: dashcore::address::Error) -> Self { + Self::AddressDecode { source } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Invariant: `MissingAccount` must hex-encode `wallet_id` like every + /// other wallet-id-bearing variant, not `Debug`-print it as thirty-two + /// bracketed decimal integers. + #[test] + fn missing_account_hex_encodes_the_wallet_id() { + let err = WalletStorageError::MissingAccount { + wallet_id: [0xa1; 32], + }; + assert_eq!( + err.to_string(), + "required account information is missing for wallet a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + ); + } + + /// Invariant: the message must not assert that something was tolerated + /// — `ensure_writable` gates on policy alone, so a clean Recovery load + /// hits this same text. It should point at `last_load_degradation`/ + /// `is_degraded` instead of instructing a repair that may not be needed. + #[test] + fn read_only_recovery_mode_points_at_the_degradation_query_instead_of_asserting_repair() { + let err = WalletStorageError::ReadOnlyRecoveryMode { operation: "flush" }; + let message = err.to_string(); + assert!(message.contains("`flush` is blocked")); + assert!(message.contains("read-only by policy")); + assert!(message.contains("last_load_degradation()")); + assert!(message.contains("is_degraded()")); + assert!( + !message.contains("repair the database"), + "message must not tell the operator to repair a database that may be healthy: {message}" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/kv.rs b/packages/rs-platform-wallet-storage/src/sqlite/kv.rs index bdf502e5b1a..8a2bb161d31 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/kv.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/kv.rs @@ -1,24 +1,14 @@ //! SQLite-backed [`KvStore`] implementation for [`SqlitePersister`]. //! -//! One dedicated table per [`ObjectId`] variant (`meta_global`, -//! `meta_wallet`, `meta_identity`, `meta_token`, `meta_contact`, -//! `meta_platform_address`). Each table has a composite PRIMARY KEY of -//! its id column(s) plus `key`, so uniqueness comes straight from the PK -//! — no partial indexes, no nullable scope column. None of the tables -//! carry an FK: a `put` succeeds before its parent object exists. The -//! `AFTER DELETE` triggers in `V001__initial.rs` clean a scope's -//! metadata up when its parent is deleted. +//! One dedicated `meta_*` table per [`ObjectId`] variant, each with a +//! composite PRIMARY KEY (id columns + `key`) for uniqueness and no FK +//! (a `put` may precede its parent; `AFTER DELETE` triggers in +//! `V001__initial.rs` reap metadata when the parent is deleted). //! -//! `match scope` resolves each operation to its table and id-column -//! bindings; the SQL body (length-precheck read, upsert, delete, -//! prefix-list) is factored once per op and parameterised by table name -//! and id predicate. Table and column names come from the matched -//! variant — compile-time constants, never caller input — so they are -//! `format!`-spliced; the id *values* are bound parameters. -//! -//! All operations reuse `SqlitePersister`'s single `Mutex` -//! via the crate-private `conn()` accessor; no separate connection is -//! opened. +//! `format!`-spliced table/column names are always compile-time constants +//! from the matched variant, never caller input; id *values* and keys are +//! bound parameters. Operations reuse the persister's single +//! `Mutex` via `conn()`. use rusqlite::{OptionalExtension, ToSql}; @@ -132,11 +122,16 @@ impl From for KvError { match err { WalletStorageError::LockPoisoned => KvError::LockPoisoned, WalletStorageError::Sqlite(e) => KvError::Sqlite(e), + // Mapped explicitly: the catch-all below would bury a + // recovery-mode refusal inside a `ToSqlConversionFailure`, + // where no caller would think to match on it. + WalletStorageError::ReadOnlyRecoveryMode { operation } => { + KvError::ReadOnlyRecoveryMode { operation } + } other => { - // Other variants don't arise from the `conn()` accessor - // — the accessor either yields `LockPoisoned` or hands - // back the guard. Stuff anything else into `Sqlite` - // via its Display, preserving the source chain. + // `conn()` only ever yields `LockPoisoned` or the guard, + // so other variants are unreachable here; preserve the + // source chain anyway by wrapping into `Sqlite`. KvError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(other))) } } @@ -152,13 +147,10 @@ impl KvStore for SqlitePersister { // Bind the id values then the key in placeholder order. let mut params: Vec<&dyn ToSql> = sql.id_vals.iter().map(|v| v as &dyn ToSql).collect(); params.push(&key); - // Single-snapshot read: select `length(value)` and `value` in one - // row. The length (column 0) is checked against `MAX_VALUE_LEN` - // before `row.get(1)` materialises the BLOB — rusqlite reads the - // BLOB lazily on that call, so the cap gates the allocation with - // no cross-snapshot TOCTOU window. The inner `Result` carries the - // over-cap length out of the closure without ever touching - // column 1. + // Select `length(value)` and `value` in one row: rusqlite reads + // the BLOB lazily on `row.get(1)`, so checking the length first + // gates the allocation with no TOCTOU window. The inner `Result` + // carries an over-cap length out without touching column 1. let row: Option, usize>> = conn .query_row( &format!("SELECT length(value), value FROM {} {where_key}", sql.table), @@ -184,10 +176,10 @@ impl KvStore for SqlitePersister { } fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError> { + self.ensure_writable("kv_put").map_err(KvError::from)?; validate_key(key)?; - // Cap the value before it reaches SQL so a `put` can never plant - // a row that a later `get` would refuse to materialise. The read - // path gates on the same `MAX_VALUE_LEN`. + // Cap before SQL so a `put` can't plant a row a later `get` would + // refuse to materialise (same `MAX_VALUE_LEN` on both paths). if value.len() > MAX_VALUE_LEN { return Err(KvError::ValueTooLarge { found: value.len(), @@ -196,9 +188,8 @@ impl KvStore for SqlitePersister { } let sql = ScopeSql::resolve(scope); let conn = self.conn().map_err(KvError::from)?; - // Column list / placeholders / conflict target all include the - // id columns ahead of `key`; the plain composite PK is the - // conflict target. Upsert refreshes `updated_at` on overwrite. + // Columns/placeholders/conflict-target put id columns ahead of + // `key` (the composite PK); upsert refreshes `updated_at`. let mut cols: Vec<&str> = sql.id_cols.to_vec(); cols.push("key"); let col_list = cols.join(", "); @@ -224,6 +215,7 @@ impl KvStore for SqlitePersister { } fn delete(&self, scope: &ObjectId, key: &str) -> Result<(), KvError> { + self.ensure_writable("kv_delete").map_err(KvError::from)?; validate_key(key)?; let sql = ScopeSql::resolve(scope); let conn = self.conn().map_err(KvError::from)?; @@ -279,6 +271,12 @@ mod tests { fn open_persister() -> (SqlitePersister, tempfile::TempDir) { let tmp = tempfile::tempdir().expect("tempdir"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)) + .expect("secure tempdir permissions"); + } let path = tmp.path().join("wallet.db"); let cfg = crate::sqlite::config::SqlitePersisterConfig::new(&path); let p = SqlitePersister::open(cfg).expect("open persister"); @@ -289,11 +287,11 @@ mod tests { let wid: WalletId = [id; 32]; let conn = p.lock_conn_for_test(); conn.execute( - "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", params![wid.as_slice()], ) - .expect("seed wallet_metadata"); + .expect("seed wallets"); wid } @@ -333,8 +331,7 @@ mod tests { #[test] fn global_composite_pk_rejects_duplicate() { - // Direct INSERTs (no ON CONFLICT) must be rejected because the - // composite PRIMARY KEY enforces per-key uniqueness. + // A direct INSERT (no ON CONFLICT) must hit the composite PK. let (p, _tmp) = open_persister(); let conn = p.lock_conn_for_test(); conn.execute( @@ -388,9 +385,8 @@ mod tests { #[test] fn get_rejects_oversized_value_before_materialising() { - // A row larger than MAX_VALUE_LEN (planted via direct SQL — - // bypassing `put`'s cap) must surface as ValueTooLarge instead - // of OOMing the process. + // A row over MAX_VALUE_LEN (planted directly, bypassing `put`) + // must surface ValueTooLarge instead of OOMing. let (p, _tmp) = open_persister(); let oversize = vec![0u8; MAX_VALUE_LEN + 1]; { @@ -442,7 +438,7 @@ mod tests { unreachable!() }; conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + "DELETE FROM wallets WHERE wallet_id = ?1", params![wid.as_slice()], ) .expect("delete wallet"); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs new file mode 100644 index 00000000000..e8a8a0875e1 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs @@ -0,0 +1,731 @@ +//! Load-time policy context — the one place [`LoadPolicy`] is branched on. +//! +//! A reader that meets a recoverable inconsistency routes it through +//! `LoadCtx::tolerate` (fatal under [`LoadPolicy::Strict`]) or +//! `LoadCtx::note_degraded` (never fatal). No site open-codes the branch, +//! so strictness cannot drift apart between readers. Both are crate-private: +//! the policy decision belongs to the readers, not to callers. +//! +//! # What "recoverable" excludes +//! +//! Not every failure is a policy question, and this module does not claim +//! otherwise: +//! +//! - **Structural failures** — a wrong-width id, an integer that will not +//! narrow, the blob-size guard — are fatal in both policies. They say the +//! row is not the shape the schema promises, which no projection survives. +//! - **Balance-bearing rows** are never dropped individually. Skipping one +//! would under-report a balance with no signal, so their failure degrades +//! the whole owning wallet instead, at [`LoadSite::WalletRehydration`], +//! and the wallet is named in [`LoadDegradation::wallets_degraded`]. +//! - **Open-time gates** run before `load()` and never reach a `LoadCtx` at +//! all — see [`LoadPolicy`]'s own documentation. +//! +//! So `Recovery` is never-fatal for the persisted-data inconsistencies a +//! reader can decline, at the cost of whichever unit — row or wallet — the +//! declining loses. + +use std::cell::{Cell, RefCell}; +use std::collections::BTreeMap; +use std::fmt::{self, Display}; + +use crate::sqlite::config::LoadPolicy; +use crate::sqlite::error::WalletStorageError; + +/// A persisted inconsistency `load()` can meet, one variant per site. +/// +/// Used as the key of [`LoadDegradation::by_site`]; [`as_str`](Self::as_str) +/// (and this type's `Display`) give the snake_case tag that appears in +/// logs. [`explanation`](Self::explanation) gives the human-readable prose +/// for the same site — the two are deliberately separate: a UI layer wants +/// the prose, a log consumer wants the tag, and neither should have to +/// derive one from the other. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LoadSite { + /// `core_sync_state.last_applied_chain_lock` failed to decode. + ChainLockBlob, + /// A `shielded_viewing_keys` row failed to decode. + ShieldedViewingKeyRow, + /// A `core_transactions` row's typed columns disagreed with its blob. + CoreTransactionColumnDrift, + /// An ECDSA account-registration row's typed columns disagreed with its blob. + AccountRegistrationDrift, + /// A provider account-registration row's typed columns disagreed with its blob. + ProviderKeyRegistrationDrift, + /// A provider account-registration row carries the wrong key curve. + ProviderKeyCurveMismatch, + /// An `asset_locks` row's typed status disagreed with its lifecycle blob. + AssetLockStatusDrift, + /// Rehydration could not derive a resolved index into an address pool. + RehydrationEnsureDerived, + /// Rehydration rejected an address pool's oversized gap-limit refill. + RehydrationGapLimit, + /// Rehydration could not maintain an address pool's gap limit. + RehydrationMaintainGapLimit, + /// A restored UTXO or used address names an account this wallet lacks. + /// Counted per address, though one record covers a whole account's. + OrphanedUtxoOwner, + /// A restored address did not resolve against its account's xpub. + /// Counted per address, though one record covers a whole account's. + UnresolvedUtxoAddress, + /// A stored UTXO or pool script could not be decoded as an address. + UndecodableAddressScript, + /// One used address resolves to two different owning accounts. + UsedAddressOwnerConflict, + /// An `identity_keys` row was unreadable or contradicted its columns. + IdentityKeyRow, + /// A `contacts` row was unreadable or contradicted its columns. + ContactRow, + /// One wallet could not be rehydrated at all; the rest of the file was. + WalletRehydration, + /// An `identity_keys` / `contacts` row's owner identity is tombstoned. + /// Counted per row, though `route_by_owner` decides once per collection + /// after its walk, so one log line can carry many counts. + TombstonedIdentityOrphan, + /// An identity owned by no wallet carries a registration index. + UnownedIdentityHasRegistrationIndex, + /// Two live `identities` rows of one wallet claim the same + /// `identity_index`. Only one can occupy the derivation slot; the loser + /// is moved to `out_of_wallet_identities` rather than dropped, since + /// nothing persisted establishes which row truly owns the slot and a + /// Recovery load is read-only — anything discarded here could never be + /// re-persisted. + IdentityIndexCollision, + /// An `identity_scan_states` row claims a complete scan while unanswered + /// indices sit beside it. Clamped toward incomplete, which costs one + /// extra scan instead of an identity that never reappears. + IdentityScanStateContradiction, + /// A `tracked_masternodes` row's `pro_tx_hash` is not 32 bytes. The + /// column is CHECK-constrained to 32, so a mismatch means the row + /// reached the file with the constraint bypassed. + TrackedMasternodeIdLength, +} + +impl LoadSite { + /// Short snake_case tag for tracing fields and per-site counter keys. + pub fn as_str(self) -> &'static str { + match self { + Self::ChainLockBlob => "chain_lock_blob", + Self::ShieldedViewingKeyRow => "shielded_viewing_key_row", + Self::CoreTransactionColumnDrift => "core_transaction_column_drift", + Self::AccountRegistrationDrift => "account_registration_drift", + Self::IdentityKeyRow => "identity_key_row", + Self::ContactRow => "contact_row", + Self::WalletRehydration => "wallet_rehydration", + Self::ProviderKeyRegistrationDrift => "provider_key_registration_drift", + Self::ProviderKeyCurveMismatch => "provider_key_curve_mismatch", + Self::AssetLockStatusDrift => "asset_lock_status_drift", + Self::RehydrationEnsureDerived => "rehydration_ensure_derived", + Self::RehydrationGapLimit => "rehydration_gap_limit", + Self::RehydrationMaintainGapLimit => "rehydration_maintain_gap_limit", + Self::OrphanedUtxoOwner => "orphaned_utxo_owner", + Self::UnresolvedUtxoAddress => "unresolved_utxo_address", + Self::UndecodableAddressScript => "undecodable_address_script", + Self::UsedAddressOwnerConflict => "used_address_owner_conflict", + Self::TombstonedIdentityOrphan => "tombstoned_identity_orphan", + Self::UnownedIdentityHasRegistrationIndex => "unowned_identity_has_registration_index", + Self::IdentityIndexCollision => "identity_index_collision", + Self::IdentityScanStateContradiction => "identity_scan_state_contradiction", + Self::TrackedMasternodeIdLength => "tracked_masternode_id_length", + } + } + + /// Human-readable prose for this site — public so a host application + /// can render *what* degraded without re-deriving eighteen strings + /// this crate already holds, or falling back to showing the user + /// [`as_str`](Self::as_str)'s log tag. This is the text every + /// `tracing::warn!` emitted for `self` also carries as its `message` + /// field, so a log reader and an API caller see the same wording. + /// + /// One entry per site, no `_` catch-all: adding a `LoadSite` must fail + /// to compile here and force a decision about its explanatory text, + /// instead of silently inheriting wording that describes it wrongly. + pub fn explanation(self) -> &'static str { + match self { + Self::ShieldedViewingKeyRow => { + "recovery mode: skipping an unreadable shielded viewing-key row" + } + Self::RehydrationEnsureDerived => { + "recovery mode: leaving an address pool short after derivation failed" + } + Self::RehydrationGapLimit => { + "recovery mode: refusing an oversized address-pool gap refill" + } + Self::RehydrationMaintainGapLimit => { + "recovery mode: leaving an address pool short after gap maintenance failed" + } + Self::TombstonedIdentityOrphan => { + "recovery mode: skipping rows owned by a tombstoned identity" + } + Self::WalletRehydration => { + "recovery mode: dropping one wallet that could not be rebuilt, keeping the rest of the file" + } + // The only two sites `note_degraded` ever reaches (see its + // callers) — never-fatal in either policy, so their prose + // describes an accepted-as-is degradation rather than a + // tolerated inconsistency. + Self::OrphanedUtxoOwner => { + "load degraded: routing addresses from an unavailable owner to the first funds account" + } + Self::UnresolvedUtxoAddress => { + "load degraded: deferring addresses that did not resolve against the account xpub" + } + // Sites whose site tag plus the logged error already say + // everything a reader needs, so they share the generic line. + Self::ChainLockBlob + | Self::CoreTransactionColumnDrift + | Self::AccountRegistrationDrift + | Self::ProviderKeyRegistrationDrift + | Self::ProviderKeyCurveMismatch + | Self::AssetLockStatusDrift + | Self::UndecodableAddressScript + | Self::UsedAddressOwnerConflict + | Self::UnownedIdentityHasRegistrationIndex + | Self::IdentityIndexCollision + | Self::IdentityScanStateContradiction + | Self::IdentityKeyRow + | Self::ContactRow + | Self::TrackedMasternodeIdLength => { + "recovery mode: tolerating a persisted inconsistency instead of failing the load" + } + } + } +} + +impl Display for LoadSite { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// What one `load()` tolerated instead of returning. +/// +/// Snapshot semantics are **per-load**: `load()` replaces the persister's +/// slot, so a database restored from backup and reloaded clean reports +/// clean. Reading the snapshot does not clear it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LoadDegradation { + /// `true` iff at least one site was tolerated — equals + /// `!by_site.is_empty()`. Never set by `unimplemented_rows`. + pub degraded: bool, + /// Sum of `by_site`'s values. + pub total: u32, + /// Per-site tolerated counts, one per occurrence — a row, an entry, a + /// blob — never one per decision the reader took. Absent sites had + /// nothing to tolerate. + pub by_site: BTreeMap, + /// Rows present in tables `load()` has no reader for. Informational: + /// the data is intact, merely not rehydrated, so it never sets + /// `degraded`. + pub unimplemented_rows: u32, + /// Wallets that were dropped whole, each mapped to the + /// [`WalletStorageError::error_kind_str`] of what stopped it. + /// + /// A count alone cannot answer the question a caller actually has here: + /// a wallet missing from `load()`'s result is otherwise indistinguishable + /// from a wallet that never existed. Raw ids rather than a wallet type, + /// matching [`SiteCoords`], which keeps this module free of them. + pub wallets_degraded: BTreeMap<[u8; 32], &'static str>, +} + +impl LoadDegradation { + /// Fold another read's tally into this one. + /// + /// The single owner of the two field invariants — `total` is the sum of + /// `by_site`, `degraded` is `!by_site.is_empty()` — so no caller + /// re-derives them and drifts. + pub(crate) fn merge(&mut self, other: Self) { + let mut by_site = std::mem::take(&mut self.by_site); + for (site, count) in other.by_site { + let slot = by_site.entry(site).or_insert(0); + *slot = slot.saturating_add(count); + } + let unimplemented_rows = self + .unimplemented_rows + .saturating_add(other.unimplemented_rows); + // First cause recorded for a wallet wins: a later read cannot know + // more about why the wallet was dropped than the read that dropped it. + let mut wallets_degraded = std::mem::take(&mut self.wallets_degraded); + for (wallet_id, cause) in other.wallets_degraded { + wallets_degraded.entry(wallet_id).or_insert(cause); + } + *self = Self::from_counts(by_site, unimplemented_rows, wallets_degraded); + } + + /// Derive the invariant fields from the raw counters. + fn from_counts( + by_site: BTreeMap, + unimplemented_rows: u32, + wallets_degraded: BTreeMap<[u8; 32], &'static str>, + ) -> Self { + Self { + degraded: !by_site.is_empty(), + total: by_site.values().copied().fold(0u32, u32::saturating_add), + by_site, + unimplemented_rows, + wallets_degraded, + } + } +} + +impl Display for LoadDegradation { + /// A short, human-readable summary: one line naming the total and site + /// count when clean or degraded, then one line per site pairing its + /// log tag with [`LoadSite::explanation`] and its tolerated count. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if !self.degraded { + return write!(f, "load not degraded"); + } + write!( + f, + "load degraded: {} inconsistenc{} tolerated across {} site{}", + self.total, + if self.total == 1 { "y" } else { "ies" }, + self.by_site.len(), + if self.by_site.len() == 1 { "" } else { "s" }, + )?; + for (site, count) in &self.by_site { + write!(f, "\n - {site} (x{count}): {}", site.explanation())?; + } + for (wallet_id, cause) in &self.wallets_degraded { + write!(f, "\n - wallet {}: {cause}", hex::encode(wallet_id))?; + } + Ok(()) + } +} + +/// Where a degraded site fired, as structured log fields. +/// +/// `account_type` and optional `detail` are `dyn Debug` so this stays free of +/// wallet types; `affected` is how many rows, addresses or entries it covers. +pub(crate) struct SiteCoords<'a> { + pub wallet_id: Option<[u8; 32]>, + pub account_type: &'a dyn fmt::Debug, + pub affected: usize, + pub detail: Option<&'a dyn fmt::Debug>, +} + +/// Per-`load()` policy + counters, created on the loading thread's stack. +/// +/// Not stored on the persister (which keeps only the resulting +/// [`LoadDegradation`]), so the interior mutability here never crosses a +/// thread boundary. +#[derive(Debug)] +pub struct LoadCtx { + policy: LoadPolicy, + counts: RefCell>, + unimplemented_rows: Cell, + wallets_degraded: RefCell>, +} + +impl LoadCtx { + /// Context for `policy`. + pub fn new(policy: LoadPolicy) -> Self { + Self { + policy, + counts: RefCell::new(BTreeMap::new()), + unimplemented_rows: Cell::new(0), + wallets_degraded: RefCell::new(BTreeMap::new()), + } + } + + /// Context that aborts the load on any inconsistency. A production + /// load takes its policy from the config; this is the shorthand for a + /// caller driving a reader directly. + pub fn strict() -> Self { + Self::new(LoadPolicy::Strict) + } + + /// Context that tolerates, logs, and counts recoverable + /// inconsistencies. The counterpart to [`strict`](Self::strict). + pub fn recovery() -> Self { + Self::new(LoadPolicy::Recovery) + } + + /// Fatal-or-tolerated dispatch for a recoverable inconsistency. + /// + /// Returns `Err(err)` under [`LoadPolicy::Strict`]. Under + /// [`LoadPolicy::Recovery`] it warns, counts `site` once, and returns + /// `Ok(())` so the caller continues with its documented degraded + /// projection. For a walk that counts several occurrences under one + /// log record, use [`tolerate_at`](Self::tolerate_at) with + /// `coords.affected` set to the count. + pub(crate) fn tolerate( + &self, + site: LoadSite, + err: WalletStorageError, + ) -> Result<(), WalletStorageError> { + if self.policy == LoadPolicy::Strict { + return Err(err); + } + self.count(site, 1); + tracing::warn!( + site = site.as_str(), + error_kind = err.error_kind_str(), + error = %err, + message = site.explanation(), + ); + Ok(()) + } + + /// [`tolerate`](Self::tolerate) with coordinates in the recovery log. + /// + /// Strict returns `err`; Recovery counts the incident and logs its error + /// kind and location. Unlike [`note_degraded`](Self::note_degraded), this + /// is never used for incidents accepted under Strict. + pub(crate) fn tolerate_at( + &self, + site: LoadSite, + coords: SiteCoords<'_>, + err: WalletStorageError, + ) -> Result<(), WalletStorageError> { + if self.policy == LoadPolicy::Strict { + return Err(err); + } + let occurrences = u32::try_from(coords.affected).unwrap_or(u32::MAX).max(1); + self.count(site, occurrences); + tracing::warn!( + site = site.as_str(), + wallet_id = ?coords.wallet_id.map(hex::encode), + account_type = ?coords.account_type, + affected = coords.affected, + detail = ?coords.detail, + error_kind = err.error_kind_str(), + error = %err, + message = site.explanation(), + ); + Ok(()) + } + + /// Record an inconsistency that is never fatal, in either policy. + /// + /// For sites whose signal cannot distinguish corruption from a healthy + /// wallet, so failing the load would brick legitimate wallets. + /// `coords` and `cause` land as fields of one record, so nothing has to + /// be joined against a neighbouring line to know where it happened, and + /// `coords.affected` is what the site counts — one incident covering + /// nine hundred addresses is nine hundred, like every other site. + pub(crate) fn note_degraded(&self, site: LoadSite, coords: SiteCoords<'_>, cause: &str) { + // Floored at one: a caller that reports nothing affected still met + // an inconsistency, and a zero would leave a site keyed with no + // count behind it. + let occurrences = u32::try_from(coords.affected).unwrap_or(u32::MAX).max(1); + self.count(site, occurrences); + tracing::warn!( + site = site.as_str(), + wallet_id = ?coords.wallet_id.map(hex::encode), + account_type = ?coords.account_type, + affected = coords.affected, + detail = ?coords.detail, + cause, + message = site.explanation(), + ); + } + + /// Add rows found in a table `load()` cannot rehydrate. Informational — + /// does not mark the load degraded. + pub(crate) fn add_unimplemented_rows(&self, rows: u32) { + self.unimplemented_rows + .set(self.unimplemented_rows.get().saturating_add(rows)); + } + + /// Snapshot the counters accumulated so far. + pub fn degradation(&self) -> LoadDegradation { + LoadDegradation::from_counts( + self.counts.borrow().clone(), + self.unimplemented_rows.get(), + self.wallets_degraded.borrow().clone(), + ) + } + + /// Attribute a whole-wallet loss to the wallet it belongs to. + /// + /// Separate from [`tolerate_at`](Self::tolerate_at), which counts the + /// event: this records WHICH wallet and WHY, because a count cannot say + /// that. First cause wins — a wallet is dropped once, by one thing. + pub(crate) fn note_wallet_degraded(&self, wallet_id: [u8; 32], cause_kind: &'static str) { + self.wallets_degraded + .borrow_mut() + .entry(wallet_id) + .or_insert(cause_kind); + } + + fn count(&self, site: LoadSite, occurrences: u32) { + let mut counts = self.counts.borrow_mut(); + let slot = counts.entry(site).or_insert(0); + *slot = slot.saturating_add(occurrences); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strict_returns_the_error_and_counts_nothing() { + let ctx = LoadCtx::strict(); + let err = ctx + .tolerate( + LoadSite::ChainLockBlob, + WalletStorageError::blob_decode("test"), + ) + .expect_err("strict must propagate"); + assert!(matches!(err, WalletStorageError::BlobDecode { .. })); + assert_eq!(ctx.degradation(), LoadDegradation::default()); + } + + #[test] + fn recovery_counts_per_site_and_sets_degraded() { + let ctx = LoadCtx::recovery(); + ctx.tolerate( + LoadSite::ChainLockBlob, + WalletStorageError::blob_decode("one"), + ) + .expect("recovery must tolerate"); + ctx.tolerate( + LoadSite::ChainLockBlob, + WalletStorageError::blob_decode("two"), + ) + .expect("recovery must tolerate"); + let snapshot = ctx.degradation(); + assert!(snapshot.degraded); + assert_eq!(snapshot.total, 2); + assert_eq!(snapshot.by_site.get(&LoadSite::ChainLockBlob), Some(&2)); + } + + /// Invariant: `tolerate_many` (public-in-name-only, its only non-test + /// caller passed a constant 1) is gone — `tolerate_at` is the one path + /// for a walk that counts several occurrences under one log record. + #[test] + fn tolerate_at_counts_every_occurrence_from_one_record() { + let ctx = LoadCtx::recovery(); + ctx.tolerate_at( + LoadSite::TombstonedIdentityOrphan, + SiteCoords { + wallet_id: Some([9u8; 32]), + account_type: &"n/a", + affected: 5, + detail: None, + }, + WalletStorageError::blob_decode("five leftover rows"), + ) + .expect("recovery must tolerate"); + let snapshot = ctx.degradation(); + assert_eq!(snapshot.total, 5); + assert_eq!( + snapshot.by_site.get(&LoadSite::TombstonedIdentityOrphan), + Some(&5) + ); + } + + /// Invariant: `tolerate` must log the site's bespoke + /// [`LoadSite::explanation`], not the old hard-coded generic literal — + /// `TombstonedIdentityOrphan` has bespoke prose that the previous + /// literal could never surface. + #[tracing_test::traced_test] + #[test] + fn tolerate_logs_the_site_explanation_as_the_message_field() { + let ctx = LoadCtx::recovery(); + ctx.tolerate( + LoadSite::TombstonedIdentityOrphan, + WalletStorageError::blob_decode("orphaned row"), + ) + .expect("recovery must tolerate"); + assert!(logs_contain( + "recovery mode: skipping rows owned by a tombstoned identity" + )); + } + + /// Invariant: `tolerate` and `tolerate_at` must both log via + /// `site.explanation()` — the same per-site text, computed the same + /// way — instead of `tolerate`'s old hard-coded literal that was + /// identical for every site regardless of which one fired. + #[tracing_test::traced_test] + #[test] + fn tolerate_and_tolerate_at_both_log_the_site_explanation() { + let ctx = LoadCtx::recovery(); + ctx.tolerate( + LoadSite::ChainLockBlob, + WalletStorageError::blob_decode("test"), + ) + .expect("recovery must tolerate"); + ctx.tolerate_at( + LoadSite::ShieldedViewingKeyRow, + SiteCoords { + wallet_id: None, + account_type: &"n/a", + affected: 1, + detail: None, + }, + WalletStorageError::blob_decode("test"), + ) + .expect("recovery must tolerate"); + assert!(logs_contain(LoadSite::ChainLockBlob.explanation())); + assert!(logs_contain(LoadSite::ShieldedViewingKeyRow.explanation())); + } + + #[test] + fn note_degraded_counts_every_affected_row() { + let ctx = LoadCtx::strict(); + ctx.note_degraded( + LoadSite::UnresolvedUtxoAddress, + SiteCoords { + wallet_id: Some([7u8; 32]), + account_type: &"Standard[0]", + affected: 900, + detail: None, + }, + "nine hundred addresses did not resolve", + ); + assert_eq!( + ctx.degradation() + .by_site + .get(&LoadSite::UnresolvedUtxoAddress), + Some(&900) + ); + } + + #[test] + fn note_degraded_counts_under_strict_too() { + let ctx = LoadCtx::strict(); + ctx.note_degraded( + LoadSite::OrphanedUtxoOwner, + SiteCoords { + wallet_id: Some([7u8; 32]), + account_type: &"Standard[0]", + affected: 1, + detail: None, + }, + "ambiguous owner", + ); + let snapshot = ctx.degradation(); + assert!(snapshot.degraded); + assert_eq!(snapshot.by_site.get(&LoadSite::OrphanedUtxoOwner), Some(&1)); + } + + #[test] + fn merge_re_derives_the_invariants_from_the_folded_counters() { + let first = LoadCtx::recovery(); + first + .tolerate( + LoadSite::ChainLockBlob, + WalletStorageError::blob_decode("one"), + ) + .expect("recovery must tolerate"); + first.add_unimplemented_rows(3); + let second = LoadCtx::recovery(); + second + .tolerate( + LoadSite::ChainLockBlob, + WalletStorageError::blob_decode("two"), + ) + .expect("recovery must tolerate"); + second + .tolerate( + LoadSite::UnownedIdentityHasRegistrationIndex, + WalletStorageError::blob_decode("three"), + ) + .expect("recovery must tolerate"); + second.add_unimplemented_rows(4); + + let mut merged = first.degradation(); + merged.merge(second.degradation()); + + assert!(merged.degraded); + assert_eq!(merged.total, 3, "total is the sum of by_site"); + assert_eq!(merged.by_site.get(&LoadSite::ChainLockBlob), Some(&2)); + assert_eq!(merged.unimplemented_rows, 7); + } + + #[test] + fn merging_only_unimplemented_rows_keeps_the_snapshot_clean() { + let rows_only = LoadCtx::strict(); + rows_only.add_unimplemented_rows(9); + + let mut merged = LoadDegradation::default(); + merged.merge(rows_only.degradation()); + + assert!(!merged.degraded); + assert_eq!(merged.total, 0); + assert_eq!(merged.unimplemented_rows, 9); + } + + #[test] + fn unimplemented_rows_do_not_set_degraded() { + let ctx = LoadCtx::strict(); + ctx.add_unimplemented_rows(7); + let snapshot = ctx.degradation(); + assert!(!snapshot.degraded); + assert_eq!(snapshot.total, 0); + assert_eq!(snapshot.unimplemented_rows, 7); + } + + /// Invariant: `note_degraded` must log the same `message` + /// field shape as `tolerate`/`tolerate_at`, carrying the site's + /// bespoke [`LoadSite::explanation`]. + #[tracing_test::traced_test] + #[test] + fn note_degraded_logs_the_site_explanation_as_the_message_field() { + let ctx = LoadCtx::strict(); + ctx.note_degraded( + LoadSite::OrphanedUtxoOwner, + SiteCoords { + wallet_id: Some([7u8; 32]), + account_type: &"Standard[0]", + affected: 1, + detail: None, + }, + "ambiguous owner", + ); + assert!(logs_contain( + "load degraded: routing addresses from an unavailable owner to the first funds account" + )); + } + + /// Invariant: `Display`/`as_str` stay the snake_case log tag — + /// `explanation` is the separate, human-readable rendering. A caller + /// must not get jargon from one and prose from the other by accident. + #[test] + fn display_is_the_tag_and_explanation_is_the_prose() { + assert_eq!( + LoadSite::IdentityIndexCollision.to_string(), + "identity_index_collision" + ); + assert_eq!( + LoadSite::IdentityIndexCollision.as_str(), + LoadSite::IdentityIndexCollision.to_string() + ); + assert_ne!( + LoadSite::IdentityIndexCollision.explanation(), + LoadSite::IdentityIndexCollision.as_str() + ); + assert!(!LoadSite::IdentityIndexCollision + .explanation() + .contains("identity_index_collision")); + } + + /// Invariant: `LoadDegradation`'s `Display` is the public rendering a + /// host app reaches for instead of re-deriving prose from `by_site`'s + /// tags — it must name the site's log tag, its count, and its prose. + #[test] + fn load_degradation_display_summarizes_by_site() { + assert_eq!(LoadDegradation::default().to_string(), "load not degraded"); + + let ctx = LoadCtx::recovery(); + ctx.tolerate( + LoadSite::ChainLockBlob, + WalletStorageError::blob_decode("one"), + ) + .expect("recovery must tolerate"); + ctx.tolerate( + LoadSite::ChainLockBlob, + WalletStorageError::blob_decode("two"), + ) + .expect("recovery must tolerate"); + let rendered = ctx.degradation().to_string(); + assert!(rendered.starts_with("load degraded: 2 inconsistencies tolerated across 1 site")); + assert!(rendered.contains("chain_lock_blob")); + assert!(rendered.contains("(x2)")); + assert!(rendered.contains(LoadSite::ChainLockBlob.explanation())); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index f313b406c73..b009d6ddeae 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -7,35 +7,147 @@ use rusqlite::OptionalExtension; use crate::sqlite::error::WalletStorageError; +use refinery_core::error::WrapMigrationError; -// `embed_migrations!` generates a `migrations` module with a `runner()` -// function. The path is relative to the crate root (where `Cargo.toml` -// lives). +mod legacy_v008; + +// Generates a `migrations` module with `runner()`; path is relative to +// the crate root. refinery::embed_migrations!("./migrations"); /// Apply every pending migration to `conn`. pub fn run(conn: &mut rusqlite::Connection) -> Result { - migrations::runner().run(conn) + run_with_runner(conn, migrations::runner()) +} + +/// Keep refinery's history validation, target selection and SQL/history writes +/// inside the same writer exclusion as the typed legacy conversion. Its native +/// rusqlite driver starts its own transactions, so this adapter implements the +/// supported driver traits over our outer transaction instead. +fn run_with_runner( + conn: &mut rusqlite::Connection, + runner: refinery::Runner, +) -> Result { + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .migration_err("begin wallet schema migration", None)?; + let embedded = migrations::runner(); + let hook_sql = |version| { + embedded + .get_migrations() + .iter() + .find(|m| m.version() == version) + .and_then(|m| m.sql()) + .expect("embedded typed migration must exist") + .to_owned() + }; + let mut driver = MigrationTransaction { + tx, + registration_sql: hook_sql(8), + pool_sql: hook_sql(11), + }; + // Grouped reports never claim that rolled-back migrations were applied. + let report = runner.set_grouped(true).run(&mut driver)?; + driver + .tx + .commit() + .migration_err("commit wallet schema migration", None)?; + Ok(report) +} + +struct MigrationTransaction<'conn> { + tx: rusqlite::Transaction<'conn>, + registration_sql: String, + pool_sql: String, +} + +impl refinery_core::traits::sync::Transaction for MigrationTransaction<'_> { + type Error = WalletStorageError; + + fn execute<'a, T: Iterator>( + &mut self, + queries: T, + ) -> Result { + let mut count = 0; + for query in queries { + self.tx.execute_batch(query)?; + if query == self.registration_sql { + legacy_v008::backfill_registrations(&self.tx)?; + } else if query == self.pool_sql { + legacy_v008::convert_pools(&self.tx)?; + } + count += 1; + } + Ok(count) + } } +impl refinery_core::traits::sync::Query> for MigrationTransaction<'_> { + fn query(&mut self, query: &str) -> Result, Self::Error> { + let mut stmt = self.tx.prepare(query)?; + let mut rows = stmt.query([])?; + let mut applied = Vec::new(); + while let Some(row) = rows.next()? { + let timestamp: String = row.get(2)?; + let applied_on = time::OffsetDateTime::parse( + ×tamp, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|_| WalletStorageError::SchemaHistoryMalformed { + reason: "applied_on is not a valid RFC3339 timestamp", + })?; + let checksum: String = row.get(3)?; + let checksum = + checksum + .parse() + .map_err(|_| WalletStorageError::SchemaHistoryMalformed { + reason: "checksum is not a valid u64", + })?; + applied.push(refinery::Migration::applied( + row.get(0)?, + row.get(1)?, + applied_on, + checksum, + )); + } + Ok(applied) + } +} + +impl refinery_core::traits::sync::Migrate for MigrationTransaction<'_> {} + /// Apply migrations on behalf of [`crate::sqlite::persister::SqlitePersister::open`]. /// -/// Plain wrapper today — V001 ships the final identity-cascade shape so -/// there is no FK-toggle dance or sentinel re-classification needed. -/// Kept as a typed-error chokepoint so future migrations that DO need -/// to re-classify a refinery error have a single entry point. +/// A typed-error chokepoint: a single entry point for any future +/// migration that needs to re-classify a refinery error. pub(crate) fn run_for_open( conn: &mut rusqlite::Connection, ) -> Result { run(conn).map_err(WalletStorageError::Migration) } -/// 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`]. +/// Return a fresh atomic migration runner seeded with the embedded list. +/// Tests can apply a subset via [`MigrationRunner::set_target`]. #[cfg(any(test, feature = "__test-helpers"))] -pub fn runner() -> refinery::Runner { - migrations::runner() +pub fn runner() -> MigrationRunner { + MigrationRunner(migrations::runner()) +} + +/// Test runner using the same atomic conversion path as production open. +#[cfg(any(test, feature = "__test-helpers"))] +pub struct MigrationRunner(refinery::Runner); + +#[cfg(any(test, feature = "__test-helpers"))] +impl MigrationRunner { + /// Stop after the selected migration, retaining staged legacy rows. + pub fn set_target(self, target: refinery::Target) -> Self { + Self(self.0.set_target(target)) + } + + /// Apply the selected migration set and typed conversions atomically. + pub fn run(self, conn: &mut rusqlite::Connection) -> Result { + run_with_runner(conn, self.0) + } } /// Highest migration version this binary knows how to apply. Used by @@ -65,13 +177,28 @@ pub(crate) fn has_schema_history(conn: &rusqlite::Connection) -> Result Result { + let exists = conn + .query_row("SELECT 1 FROM sqlite_master LIMIT 1", [], |_| Ok(())) + .optional()? + .is_some(); + Ok(exists) +} + +/// Refuse to operate on a DB whose `refinery_schema_history` MAX(version) +/// exceeds [`max_supported_version`], returning +/// [`WalletStorageError::SchemaVersionUnsupported`]. This is a forward-only +/// gate — it refuses a newer DB but never migrates it down (SQLite +/// migrations are one-directional). /// -/// 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"). +/// Quietly succeeds when the table is absent; the caller decides what a +/// missing schema-history means (`restore_from` rejects it, `open` treats +/// it as a brand-new DB). pub fn assert_schema_version_supported( conn: &rusqlite::Connection, ) -> Result<(), WalletStorageError> { @@ -98,6 +225,105 @@ pub fn assert_schema_version_supported( Ok(()) } +fn refinery_timestamp_is_valid(value: &str) -> bool { + let bytes = value.as_bytes(); + let digits = |range: std::ops::Range| bytes[range].iter().all(u8::is_ascii_digit); + let shape_is_valid = bytes.len() >= 20 + && bytes.len() <= 30 + && digits(0..4) + && bytes[4] == b'-' + && digits(5..7) + && bytes[7] == b'-' + && digits(8..10) + && bytes[10] == b'T' + && digits(11..13) + && bytes[13] == b':' + && digits(14..16) + && bytes[16] == b':' + && digits(17..19) + && bytes[17] <= b'5' + && bytes[bytes.len() - 1] == b'Z' + && (bytes.len() == 20 || (bytes[19] == b'.' && digits(20..bytes.len().saturating_sub(1)))); + + shape_is_valid && chrono::DateTime::parse_from_rfc3339(value).is_ok() +} + +/// Probe `refinery_schema_history` rows BEFORE handing the connection to +/// refinery, which parses `applied_on` (RFC3339) and `checksum` (`u64`) +/// with `unwrap()` — a malformed value would abort the process. Surfaces +/// a typed [`WalletStorageError::SchemaHistoryMalformed`] instead. +/// Quietly succeeds when the table is absent. +pub(crate) fn assert_schema_history_well_formed( + conn: &rusqlite::Connection, +) -> Result<(), WalletStorageError> { + if !has_schema_history(conn)? { + return Ok(()); + } + let mut stmt = conn.prepare("SELECT applied_on, checksum FROM refinery_schema_history")?; + let rows = stmt.query_map([], |row| { + let applied_on: String = row.get(0)?; + let checksum: String = row.get(1)?; + Ok((applied_on, checksum)) + })?; + for row in rows { + let (applied_on, checksum) = row?; + // Deliberately NARROWER than refinery's own reader, which accepts any + // RFC3339 value (`drivers/rusqlite.rs` parses with `time::Rfc3339`). + // We can be narrower because refinery only ever WRITES the canonical + // UTC shape: `runner.rs` stamps `OffsetDateTime::now_utc()` and + // `traits/mod.rs` formats it with `time::Rfc3339`, which emits a + // trailing `Z` for a UTC offset rather than `+00:00`. So every value + // refinery produced passes this gate, while a hand-written `+00:00` + // is refused — a typed error, which is the point: the panic this + // guard replaces is unrecoverable. + if !refinery_timestamp_is_valid(&applied_on) { + return Err(WalletStorageError::SchemaHistoryMalformed { + reason: "applied_on is not a valid RFC3339 timestamp", + }); + } + if checksum.parse::().is_err() { + return Err(WalletStorageError::SchemaHistoryMalformed { + reason: "checksum is not a valid u64", + }); + } + } + Ok(()) +} + +/// True when `refinery_schema_history` is non-empty and every applied +/// `(version, name)` pair names a migration this binary embeds at that same +/// version. +/// +/// This is the positive identification behind +/// [`crate::sqlite::conn::assert_wallet_application_id_or_legacy`]: a foreign +/// SQLite database does not carry our migration names, so an unstamped file +/// that passes here is a wallet database created before the header stamp +/// existed. Deliberately name-only — a checksum comparison is refinery's job +/// moments later, and doing it here would report a divergent body as +/// "not a wallet database", which is the wrong diagnosis. +pub(crate) fn applied_history_matches_embedded( + conn: &rusqlite::Connection, +) -> Result { + let embedded: std::collections::HashMap = + embedded_migrations().into_iter().collect(); + let mut stmt = conn.prepare("SELECT version, name FROM refinery_schema_history")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })?; + let mut saw_row = false; + for row in rows { + let (version, name) = row?; + saw_row = true; + let Ok(version) = i32::try_from(version) else { + return Ok(false); + }; + if embedded.get(&version) != Some(&name) { + return Ok(false); + } + } + Ok(saw_row) +} + /// List `(version, name)` of every embedded migration. Used by tests and /// the migration-drift hash check. pub fn embedded_migrations() -> Vec<(i32, String)> { @@ -109,8 +335,10 @@ pub fn embedded_migrations() -> Vec<(i32, String)> { } /// SHA-256 over `(version, name)` of every embedded migration in version -/// order. Pinning this in tests catches edits to committed migrations -/// (forbidden by the append-only migration policy). +/// order. Deliberately content-blind: it hashes the migration set's +/// identity, not the SQL bodies, so it catches an added/removed/renamed +/// migration but ignores in-place DDL edits (a content-pinning guard +/// belongs with the schema freeze at release). #[cfg(any(test, feature = "__test-helpers"))] pub fn embedded_migrations_fingerprint() -> [u8; 32] { use sha2::{Digest, Sha256}; @@ -126,10 +354,100 @@ pub fn embedded_migrations_fingerprint() -> [u8; 32] { hasher.finalize().into() } +/// SHA-256 over `(version, name, rendered SQL)` of every embedded migration +/// in version order. Unlike [`embedded_migrations_fingerprint`] this is +/// content-level: it pins each migration's SQL body, so an in-place DDL edit +/// (e.g. renaming a table inside a same-named file) breaks the golden test. +/// This is the guard the D0 schema freeze relies on; the identity-only +/// fingerprint cannot catch a same-name body edit. +/// +/// The SQL *text* is deterministic even where a value is generated at run +/// time (`randomblob(16)`): the literal string is hashed, not the runtime +/// bytes. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn embedded_migrations_sql_fingerprint() -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut migrations = migrations::runner().get_migrations().clone(); + migrations.sort_by_key(|m| m.version()); + let mut hasher = Sha256::new(); + for m in &migrations { + hasher.update((m.version() as u32).to_be_bytes()); + hasher.update([0u8]); + hasher.update(m.name().as_bytes()); + hasher.update([0u8]); + let sql = m + .sql() + .expect("embedded migrations always carry rendered SQL"); + hasher.update(sql.as_bytes()); + hasher.update([0u8]); + } + hasher.finalize().into() +} + +/// Rendered SQL of every embedded migration paired with its version, in +/// version order. The retired-name guard needs the version to skip the +/// pre-rename history, where a retired table name is correct. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn embedded_migrations_sql_by_version() -> Vec<(i32, String)> { + let mut migrations = migrations::runner().get_migrations().clone(); + migrations.sort_by_key(|m| m.version()); + migrations + .iter() + .map(|m| { + ( + m.version(), + m.sql() + .expect("embedded migrations always carry rendered SQL") + .to_string(), + ) + }) + .collect() +} + +/// Rendered SQL of every embedded migration, in version order. Used by the +/// schema-freeze grep guard to scan for retired table names. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn embedded_migrations_sql() -> Vec { + let mut migrations = migrations::runner().get_migrations().clone(); + migrations.sort_by_key(|m| m.version()); + migrations + .iter() + .map(|m| { + m.sql() + .expect("embedded migrations always carry rendered SQL") + .to_string() + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; - use rusqlite::Connection; + use rusqlite::{params, Connection}; + + #[test] + fn schema_history_guard_rejects_timestamps_refinery_rejects() { + for applied_on in ["2024-01-01T00:00:00−00:00", "2024-01-01T00:00:60Z"] { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE refinery_schema_history ( + applied_on TEXT NOT NULL, + checksum TEXT NOT NULL + )", + ) + .unwrap(); + conn.execute( + "INSERT INTO refinery_schema_history (applied_on, checksum) VALUES (?1, '0')", + params![applied_on], + ) + .unwrap(); + + assert!(matches!( + assert_schema_history_well_formed(&conn), + Err(WalletStorageError::SchemaHistoryMalformed { .. }) + )); + } + } /// The helper returns false on a brand-new in-memory DB (no /// `refinery_schema_history`), and true after the table is created. @@ -174,11 +492,8 @@ mod tests { /// The initial schema (V001) creates the DashPay sync-correctness /// objects directly — the `contacts.payment_channel_broken` column and - /// the `ignored_senders` table. The storage crate is pre-release with no - /// product consumers yet (nothing instantiates `SqlitePersister` or runs - /// these migrations), so V001 is edited in place rather than amended by a - /// follow-on migration — no real database has ever applied it. This test - /// pins that the objects exist after the (only) migration runs. + /// the `ignored_senders` table. V001-V007 are published and immutable; + /// this test pins that these objects survive the full migration set. #[test] fn v001_creates_dashpay_sync_schema() { let mut conn = Connection::open_in_memory().unwrap(); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v008.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v008.rs new file mode 100644 index 00000000000..b1d7d208feb --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v008.rs @@ -0,0 +1,226 @@ +//! Typed conversion of the published V001-V007 state. These legacy table +//! names belong here only: V008 retains them until V011 has all destination +//! columns, and the enclosing migration transaction drops them after success. + +use dashcore::Address; +use key_wallet::account::derivation::AccountDerivation; +use key_wallet::account::{Account, AccountType}; +use key_wallet::managed_account::address_pool::{AddressPoolType, AddressState}; +use key_wallet::AddressInfo; +use platform_wallet::changeset::{AccountAddressPoolEntry, AccountRegistrationEntry}; +use rusqlite::{params, Transaction}; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::{accounts, blob, core_pool, id32, wallets}; + +fn invalid(reason: &'static str) -> WalletStorageError { + WalletStorageError::blob_decode(reason) +} + +fn matches_account(label: &str, index: i64, account_type: &AccountType) -> bool { + accounts::db_label_matches_entry(label, account_type) + && index == i64::from(accounts::account_index(account_type)) +} + +fn pool_type(label: &str) -> Result { + match label { + "external" => Ok(AddressPoolType::External), + "internal" => Ok(AddressPoolType::Internal), + "absent" => Ok(AddressPoolType::Absent), + "absent_hardened" => Ok(AddressPoolType::AbsentHardened), + _ => Err(invalid("legacy pool type is invalid")), + } +} + +pub(super) fn backfill_registrations(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare( + "SELECT wallet_id, account_type, account_index, length(account_xpub_bytes), account_xpub_bytes + FROM account_registrations", + )?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let wallet_id: Vec = row.get(0)?; + let label: String = row.get(1)?; + let index: i64 = row.get(2)?; + blob::check_size(row.get(3)?)?; + let entry: AccountRegistrationEntry = blob::decode(&row.get::<_, Vec>(4)?)?; + if !matches_account(&label, index, &entry.account_type) { + return Err(WalletStorageError::AccountRegistrationEntryMismatch); + } + let (user, friend) = accounts::account_dashpay_ids(&entry.account_type); + tx.execute( + "UPDATE account_registrations SET key_class = ?1, user_identity_id = ?2, + friend_identity_id = ?3 WHERE wallet_id = ?4 AND account_type = ?5 AND account_index = ?6", + params![accounts::account_key_class(&entry.account_type), user.as_slice(), + friend.as_slice(), wallet_id, label, index], + )?; + } + Ok(()) +} + +pub(super) fn convert_pools(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { + // Join through wallets, preserving V008's established orphan policy: rows + // left behind with foreign keys disabled are unreachable and may be swept. + let mut wallet_stmt = tx.prepare("SELECT wallet_id, network FROM wallets")?; + let mut wallet_rows = wallet_stmt.query([])?; + while let Some(row) = wallet_rows.next()? { + let wallet_id = id32("wallets.wallet_id", &row.get::<_, Vec>(0)?)?; + let network = wallets::parse_network(&row.get::<_, String>(1)?) + .ok_or_else(|| invalid("legacy wallet network is invalid"))?; + let mut pools = Vec::::new(); + let mut stmt = tx.prepare( + "SELECT account_type, account_index, pool_type, length(snapshot_blob), snapshot_blob + FROM account_address_pools WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query([wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let label: String = row.get(0)?; + let index: i64 = row.get(1)?; + let kind = pool_type(&row.get::<_, String>(2)?)?; + blob::check_size(row.get(3)?)?; + let entry: AccountAddressPoolEntry = blob::decode(&row.get::<_, Vec>(4)?)?; + if !matches_account(&label, index, &entry.account_type) || kind != entry.pool_type { + return Err(invalid("legacy pool columns disagree with snapshot")); + } + let mut indices = std::collections::HashSet::new(); + for info in &entry.addresses { + if info.address.script_pubkey() != info.script_pubkey || !indices.insert(info.index) + { + return Err(invalid("legacy pool has conflicting address data")); + } + } + pools.push(entry); + } + + let mut registrations = Vec::::new(); + let mut stmt = tx.prepare( + "SELECT length(account_xpub_bytes), account_xpub_bytes FROM account_registrations + WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query([wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_size(row.get(0)?)?; + registrations.push(blob::decode(&row.get::<_, Vec>(1)?)?); + } + + let mut stmt = tx.prepare( + "SELECT account_type, account_index, address, derivation_path, used + FROM core_derived_addresses WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query([wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let label: String = row.get(0)?; + let account_index: i64 = row.get(1)?; + let address = row + .get::<_, String>(2)? + .parse::>() + .map_err(|_| invalid("legacy derived address is invalid"))? + .require_network(network) + .map_err(|_| invalid("legacy derived address network disagrees with wallet"))?; + let path: String = row.get(3)?; + // The published writer stored `pool_type/index`, not a BIP32 path. + let (kind, index) = path + .split_once('/') + .ok_or_else(|| invalid("legacy derived address path is invalid"))?; + let kind = pool_type(kind)?; + let index: u32 = index + .parse() + .map_err(|_| invalid("legacy derived address index is invalid"))?; + let used: i64 = row.get(4)?; + if !(0..=1).contains(&used) { + return Err(invalid("legacy derived address used flag is invalid")); + } + + // A matching snapshot proves ownership even for a hardened pool + // whose address cannot be regenerated from a public account key. + let matching = pools + .iter() + .enumerate() + .filter_map(|(p, pool)| { + if matches_account(&label, account_index, &pool.account_type) + && pool.pool_type == kind + { + pool.addresses + .iter() + .position(|info| info.index == index && info.address == address) + .map(|i| (p, i)) + } else { + None + } + }) + .collect::>(); + if let [(p, i)] = matching.as_slice() { + if used == 1 { + pools[*p].addresses[*i].state = AddressState::Used; + } + continue; + } + if !matching.is_empty() { + return Err(invalid("legacy derived address ownership is ambiguous")); + } + + // The old row's label/index is authoritative when it names one + // full account identity. Requiring public derivation here would + // reject legitimate hardened addresses. Only ambiguous aliases + // need additional evidence from an available account xpub. + let mut owners = registrations + .iter() + .map(|entry| entry.account_type) + .chain(pools.iter().map(|entry| entry.account_type)) + .filter(|owner| matches_account(&label, account_index, owner)) + .collect::>(); + let mut seen = std::collections::HashSet::new(); + owners.retain(|owner| seen.insert(*owner)); + if owners.len() > 1 { + owners.retain(|owner| { + registrations + .iter() + .filter(|entry| entry.account_type == *owner) + .any(|entry| { + Account::new( + Some(wallet_id), + entry.account_type, + entry.account_xpub, + network, + ) + .and_then(|account| account.derive_address_at(kind, index, None)) + .is_ok_and(|derived| derived == address) + }) + }); + } + let [owner] = owners.as_slice() else { + return Err(invalid("legacy derived address has no unique proven owner")); + }; + let mut info = AddressInfo::new_from_script_pubkey_p2pkh( + address.script_pubkey(), + index, + Default::default(), + network, + ) + .map_err(|_| invalid("legacy derived address script is invalid"))?; + if used == 1 { + info.state = AddressState::Used; + } + if let Some(pool) = pools + .iter_mut() + .find(|pool| pool.account_type == *owner && pool.pool_type == kind) + { + if pool.addresses.iter().any(|info| info.index == index) { + return Err(invalid( + "legacy derived address conflicts with snapshot slot", + )); + } + pool.addresses.push(info); + } else { + pools.push(AccountAddressPoolEntry { + account_type: *owner, + pool_type: kind, + addresses: vec![info], + }); + } + } + core_pool::apply_pools(tx, &wallet_id, &pools)?; + } + tx.execute_batch("DROP TABLE account_address_pools; DROP TABLE core_derived_addresses;")?; + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs index c669e885dd6..6ed1f6a26ee 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs @@ -14,6 +14,8 @@ pub mod error; #[cfg(feature = "kv")] pub mod kv; pub mod persister; +mod provider_accounts; +pub mod rehydrate; pub mod reports; pub mod util; @@ -30,9 +32,22 @@ pub mod schema; #[cfg(not(any(test, feature = "__test-helpers")))] pub(crate) mod schema; +// `LoadCtx` is the policy input every core-state reader takes, and +// `rehydrate::apply_persisted_core_state` — the crate's public rehydration +// entry point — takes one too, so it is public unconditionally. +pub mod load_ctx; + pub use config::{ - default_auto_backup_dir, FlushMode, JournalMode, SqlitePersisterConfig, Synchronous, + default_auto_backup_dir, FlushMode, JournalMode, LoadPolicy, SqlitePersisterConfig, Synchronous, }; pub use error::{AutoBackupOperation, WalletStorageError}; -pub use persister::{PruneReport, RetentionPolicy, SqlitePersister}; +pub use load_ctx::{LoadCtx, LoadDegradation, LoadSite}; +// `OwningAccount` names two of `rehydrate::apply_persisted_core_state`'s +// parameters, and `schema` is `pub(crate)` unless `__test-helpers` is on — a +// feature downstream MUST NOT enable. Re-exported for the same reason +// `load_ctx` is public: a public entry point whose parameter types cannot be +// named from a default build is not callable from one. +pub use persister::{prune_backups_in, PruneReport, RetentionPolicy, SqlitePersister}; pub use reports::{CommitReport, DeleteWalletReport}; +#[doc(inline)] +pub use schema::core_pool::OwningAccount; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index dd02595121e..ad315d9bb86 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1,63 +1,93 @@ //! [`SqlitePersister`] — the canonical `PlatformWalletPersistence` impl. +use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use rusqlite::{Connection, OptionalExtension}; +use dpp::prelude::Identifier; use platform_wallet::changeset::{ - ClientStartState, Merge, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, - PlatformWalletPersistence, + ClientStartState, IdentityChangeSet, Merge, PersistenceCapabilities, PersistenceError, + PlatformWalletChangeSet, PlatformWalletPersistence, }; +use platform_wallet::wallet::identity::ManagedIdentity; 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::config::{FlushMode, LoadPolicy, SqlitePersisterConfig, Synchronous}; use crate::sqlite::error::{AutoBackupOperation, WalletStorageError}; +use crate::sqlite::load_ctx::{LoadCtx, LoadDegradation, LoadSite}; +use crate::sqlite::rehydrate::{ + apply_persisted_core_state, build_wallet, restore_provider_platform_node_pool, +}; use crate::sqlite::reports::{CommitReport, DeleteWalletReport}; use crate::sqlite::schema; use crate::sqlite::util::permissions::{apply_secure_permissions, precreate_secure}; use crate::sqlite::util::safe_cast; -/// Sub-areas of `ClientStartState` that `load()` does not yet -/// reconstruct (blocked on upstream `Wallet::from_persisted`). +/// Persisted-but-not-rehydrated areas, surfaced in the structured +/// `tracing::info!` summary on every `load()`. /// -/// Surfaced via the structured `tracing::info!` summary on every -/// `load()` (`unimplemented` + `wallets_pending_rehydration` fields). -pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = &["ClientStartState::wallets"]; +/// - `token_balances`: written by the `token_balances` slot but not read +/// back by `load()` (no reader wired in yet). +/// - `dashpay::overlay`: the `dashpay_profiles` / +/// `dashpay_payments_overlay` tables are a write-only indexed overlay; +/// DashPay state rehydrates from the identities blob, not these tables. +/// - `pending_contact_crypto`: the deferred contact-crypto queue is written +/// on the production path and has no production reader, so a restart +/// abandons it. Listed so the loss is at least COUNTED; wiring a reader is +/// a behaviour change, not an accounting one. +/// - `invitations`: deliberately not rehydrated — the Swift SwiftData mirror +/// is the UI's source — which is precisely what this list is for. +pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = &[ + "token_balances", + "dashpay::overlay", + "pending_contact_crypto", + "invitations", +]; + +/// Tables backing [`LOAD_UNIMPLEMENTED`], probed for a row count so a +/// `load()` can report how much persisted state it did not rehydrate. +/// Compile-time constants, never caller input, so splicing them into the +/// probe SQL is safe. +const LOAD_UNIMPLEMENTED_TABLES: &[&str] = &[ + "token_balances", + "dashpay_profiles", + "dashpay_payments_overlay", + "pending_contact_crypto", + "invitations", +]; + +/// The all-zero `WalletId` reserved as the storage spelling of "owned by +/// no wallet": every scope-aware reader and writer maps it to a NULL +/// `wallet_id`. It is never a real wallet id. +const UNOWNED_SCOPE: WalletId = [0u8; 32]; /// 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. +/// Invariant: `kept == total_eligible - removed.len()`; a file is `kept` +/// if the policy retained it OR `remove_file` failed (so `failed_removals` +/// is a subset of `kept`). Either way it's still on disk. #[derive(Debug)] pub struct PruneReport { - /// Paths that were unlinked, sorted oldest-first by filename - /// timestamp. + /// Unlinked paths, 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". + /// Count still on disk (`total_eligible - removed.len()`), including + /// every `failed_removals` entry. 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. + /// Files we couldn't remove, paired with the `io::Error`. Returned in + /// `Ok(report)` so the caller can re-invoke to retry the stragglers. 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. +/// `keep_last_n` is a **floor**: the N newest backups are always kept even +/// if `max_age` would evict them, so a policy setting both can never delete +/// everything. `keep_last_n = None` gives no floor (age-only may prune +/// all); `default()` (both `None`) keeps every file. #[derive(Debug, Clone, Copy, Default)] pub struct RetentionPolicy { pub keep_last_n: Option, @@ -79,26 +109,144 @@ impl RetentionPolicy { } } +/// Apply retention to a directory of backup files without opening a +/// database. +/// +/// For tooling that holds no persister — the maintenance CLI's `prune` +/// subcommand is the intended caller. **This carries no recovery-mode +/// gate.** Whenever a [`SqlitePersister`] is open, call +/// [`SqlitePersister::prune_backups`] instead: it refuses in +/// [`LoadPolicy::Recovery`](crate::LoadPolicy), so a user rescuing a +/// damaged database cannot shrink their own rollback set. +/// +/// # Errors +/// +/// [`WalletStorageError::Io`] when `dir` cannot be read. Per-file removal +/// failures are collected into [`PruneReport::failed_removals`] instead. +pub fn prune_backups_in( + dir: &Path, + policy: RetentionPolicy, +) -> Result { + backup::prune(dir, policy) +} + +/// Canonicalized paths held by a live [`SqlitePersister`] in this process. +/// Refusing a second in-process open ([`WalletStorageError::AlreadyOpen`]) +/// prevents two handles with independent buffers diverging; cross-process +/// peers are handled by SQLite's own EXCLUSIVE locking. +fn open_path_registry() -> &'static Mutex> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashSet::new())) +} + +/// Insert `path`, returning [`WalletStorageError::AlreadyOpen`] if held. +/// Recover from a poisoned registry mutex rather than wedging every open. +fn register_open_path(path: PathBuf) -> Result<(), WalletStorageError> { + let mut set = open_path_registry() + .lock() + .unwrap_or_else(|p| p.into_inner()); + if set.contains(&path) { + return Err(WalletStorageError::AlreadyOpen { path }); + } + set.insert(path); + Ok(()) +} + +/// Registry key for the database at `path`, whose parent is `parent`. +/// +/// The canonical parent joined with the file name, NOT `canonicalize(path)`: +/// the claim is taken before the database is created, and `Path::canonicalize` +/// cannot resolve a path that does not exist yet, so keying on the whole path +/// would hand two spellings of one not-yet-created database two different keys. +/// Both callers verify `parent` exists first. A symlinked database is refused +/// by `precreate_secure`, so for every path that actually opens this equals +/// `canonicalize(path)`. +fn registry_key(path: &Path, parent: &Path) -> PathBuf { + match (parent.canonicalize(), path.file_name()) { + (Ok(dir), Some(name)) => dir.join(name), + _ => path.to_path_buf(), + } +} + +/// A live claim in the open-path registry, released on drop unless the +/// persister takes it over. +/// +/// Claiming EARLY is what closes the window in which two concurrent opens both +/// compute their pending-migration list from the same pre-migration history and +/// both apply it; releasing on drop is what keeps a failed open from leaving a +/// claim nobody will ever remove. The two properties are independent, and the +/// guard is what lets the code have both. +struct OpenPathClaim { + path: PathBuf, + armed: bool, +} + +impl OpenPathClaim { + /// Claim `path`, or fail with [`WalletStorageError::AlreadyOpen`]. + fn claim(path: PathBuf) -> Result { + register_open_path(path.clone())?; + Ok(Self { path, armed: true }) + } + + /// Hand the claimed path to the persister that will hold it; the guard + /// stops releasing it, and the persister's `Drop` takes over. + fn into_held_path(mut self) -> PathBuf { + self.armed = false; + std::mem::take(&mut self.path) + } +} + +impl Drop for OpenPathClaim { + fn drop(&mut self) { + if self.armed { + release_open_path(&self.path); + } + } +} + +/// Remove `path` from the open-path registry on persister drop. +fn release_open_path(path: &Path) { + let mut set = open_path_registry() + .lock() + .unwrap_or_else(|p| p.into_inner()); + set.remove(path); +} + +/// `true` if `path` is held open by a live [`SqlitePersister`] in this +/// process. Callers pass a canonicalized path (matching how `open()` +/// registers it). +fn is_path_open(path: &Path) -> bool { + open_path_registry() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .contains(path) +} + /// SQLite-backed `PlatformWalletPersistence`. pub struct SqlitePersister { config: SqlitePersisterConfig, - // Single connection serializes reads through the write lock. - // Acceptable for the current workload (per-wallet operations, small - // read footprint); a read-only pool over the same WAL-mode file is + /// Canonicalized DB path held in the process-wide open-path registry. + /// Removed from the registry when this persister drops. + registered_path: PathBuf, + // Single connection serializes reads through the write lock — + // acceptable for the current per-wallet workload; a read-only pool is // the planned follow-up if read contention becomes measurable. 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"))]`). + /// What the most recent `load()` tolerated. Replaced per `load()`, so + /// a repaired database that reloads clean reports clean. + last_load_degradation: Mutex, + /// Test-only one-shot injector for `flush_inner`. #[cfg(any(test, feature = "__test-helpers"))] primed_flush_error: Mutex>, - /// Test-only one-shot injection consumed by `delete_wallet`'s - /// pre-flush phase. Lets a test assert the buffer-restore and - /// skip-backup semantics without provoking a real SQL error. + /// Test-only one-shot injector for `delete_wallet`'s pre-flush phase. #[cfg(any(test, feature = "__test-helpers"))] primed_pre_flush_error: Mutex>, + /// Test-only rendezvous fired between `store()`'s buffer merge and + /// its flush, so a test can drive another flusher at that seam + /// instead of racing for it. + #[cfg(any(test, feature = "__test-helpers"))] + store_flush_seam: Mutex>>, } impl SqlitePersister { @@ -113,6 +261,8 @@ impl SqlitePersister { /// - [`WalletStorageError::Io`] (kind `NotFound`) — the parent of /// `config.path` does not exist. The persister refuses to create /// parent directories silently. + /// - [`WalletStorageError::InsecureParentDir`] — a database ancestor has + /// unsafe replacement permissions or ownership on Unix. /// - [`WalletStorageError::ForeignKeysNotEnforced`] — the linked /// SQLite build silently ignores `PRAGMA foreign_keys = ON` /// (no FK support compiled in). @@ -128,59 +278,98 @@ impl SqlitePersister { /// [`WalletStorageError::AutoBackupDisabled`] — the /// pre-migration auto-backup couldn't materialise. pub fn open(config: SqlitePersisterConfig) -> Result { + // Log every open failure where it surfaces — this is the crate's + // highest-stakes boundary (on-disk corruption, forward-incompatible + // schema, mid-run migration failure, in-process double-open) and the + // caller only sees the returned `Err`, not why it happened. + // `AlreadyOpen` is the one benign race (the loser retries once the + // winner drops), so it warns rather than errors. + let path = config.path.clone(); + Self::open_inner(config).inspect_err(|e| match e { + WalletStorageError::AlreadyOpen { .. } => tracing::warn!( + path = %path.display(), + error_kind = e.error_kind_str(), + "SqlitePersister open refused: database already open in this process" + ), + _ => tracing::error!( + path = %path.display(), + error_kind = e.error_kind_str(), + error = %e, + "SqlitePersister failed to open database" + ), + }) + } + + fn open_inner(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 to create it silently so - // "bad path" stays a typed error. - return Err(WalletStorageError::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("database parent directory not found: {}", parent.display()), - ))); - } + let parent = config + .path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + if !parent.exists() { + return Err(WalletStorageError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("database parent directory not found: {}", parent.display()), + ))); } + crate::parent_permissions::check_parent_perms(parent).map_err(|error| match error { + crate::parent_permissions::ParentPermissionsError::Io(source) => { + WalletStorageError::Io(source) + } + crate::parent_permissions::ParentPermissionsError::Insecure { ancestor, reason } => { + WalletStorageError::InsecureParentDir { ancestor, reason } + } + })?; - // Pre-create the DB file owner-only (0600) with O_EXCL BEFORE - // rusqlite opens it: the file is born at 0600 (no umask window) - // and an attacker-planted symlink at the path makes the create - // fail rather than redirect (no chmod-by-path TOCTOU). A no-op - // when the DB already exists. Brings the SQLite path to parity - // with the secrets-vault file path. + // Claim the path BEFORE anything touches the file. The registry exists + // to stop two handles diverging, and the most destructive thing an + // unguarded second open does is re-run migrations the first has not + // committed yet. The guard releases the claim on every error path out + // of this function, so a failed open still leaves no stale claim. + let claim = OpenPathClaim::claim(registry_key(&config.path, parent))?; + + // Pre-create owner-only (0600) with O_EXCL before rusqlite opens: + // no umask window, and a planted symlink makes the create fail + // rather than redirect (no chmod-by-path TOCTOU). No-op if it + // already exists. precreate_secure(&config.path)?; - // 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. + // Open + apply pragmas before checking pending migrations so the + // integrity probe sees the configured journal mode / busy timeout. let mut conn = crate::sqlite::conn::open_conn(&config.path, crate::sqlite::conn::Access::ReadWrite)?; - // Re-tighten to 0600 on Unix (idempotent on re-open) and sweep - // the WAL/SHM sidecars that SQLite creates after open. + // Re-tighten to 0600 and sweep the WAL/SHM sidecars SQLite created. 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?". Errors from the underlying query are propagated, - // not silently treated as "no history". + // `schema_history` presence is the pre-existing-vs-brand-new + // signal; query errors propagate rather than masking as "none". let had_schema_history = crate::sqlite::migrations::has_schema_history(&conn)?; - // 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. + // Integrity-check a pre-existing DB BEFORE migrations alter it, + // else a corrupt DB gets backed up and migrated in one pass, + // making the pre-migration auto-backup useless for rollback. if had_schema_history { crate::sqlite::backup::run_integrity_check(&conn, |report| { WalletStorageError::IntegrityCheckFailed { report } })?; } - // 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). + // Refuse a newer-binary DB: refinery's run() no-ops at + // pending==0, after which blob decoders would read forward-schema + // bytes. Then assert the wallet application_id and a well-formed + // schema_history BEFORE refinery, so a foreign or + // corrupted-but-integrity-valid DB fails typed instead of being + // migrated in place or panicking the runner. if had_schema_history { crate::sqlite::migrations::assert_schema_version_supported(&conn)?; + crate::sqlite::conn::assert_wallet_application_id_or_legacy(&conn)?; + crate::sqlite::migrations::assert_schema_history_well_formed(&conn)?; + } else if crate::sqlite::migrations::db_has_objects(&conn)? { + // A pre-existing file with schema objects but NO refinery history is + // a foreign (non-wallet) SQLite DB. Migrating it in place would graft + // wallet tables onto someone else's schema; reject via the + // application_id gate (a foreign DB never carries our magic) instead. + crate::sqlite::conn::assert_wallet_application_id(&conn)?; } let pending = crate::sqlite::migrations::embedded_migrations(); let pending_count = if had_schema_history { @@ -192,30 +381,123 @@ impl SqlitePersister { 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); + let db_stem = backup::sanitize_db_stem(&config.path); run_auto_backup( &conn, config.auto_backup_dir.as_deref(), - BackupKind::PreMigration { from, to }, + BackupKind::PreMigration { + db_stem: &db_stem, + from, + to, + }, AutoBackupOperation::OpenMigration, )?; } - // Apply migrations through the typed-error chokepoint. let _report = crate::sqlite::migrations::run_for_open(&mut conn)?; + // The open succeeded, so the claim passes to the persister, whose + // `Drop` releases it. + let registered_path = claim.into_held_path(); + Ok(Self { config, + registered_path, conn: Arc::new(Mutex::new(conn)), buffer: Buffer::new(), + last_load_degradation: Mutex::new(LoadDegradation::default()), #[cfg(any(test, feature = "__test-helpers"))] primed_flush_error: Mutex::new(None), #[cfg(any(test, feature = "__test-helpers"))] primed_pre_flush_error: Mutex::new(None), + #[cfg(any(test, feature = "__test-helpers"))] + store_flush_seam: Mutex::new(None), }) } + /// Refuse a mutating `operation` while this persister is in + /// [`LoadPolicy::Recovery`]. + /// + /// Recovery serves a degraded projection of the persisted rows, so + /// every write would risk committing the tolerated view over the good + /// data. The exit from recovery mode is + /// [`restore_from`](Self::restore_from) followed by a reopen under + /// [`LoadPolicy::Strict`], not a write from inside it. + /// + /// # Errors + /// + /// [`WalletStorageError::ReadOnlyRecoveryMode`] naming `operation`. + pub(crate) fn ensure_writable( + &self, + operation: &'static str, + ) -> Result<(), WalletStorageError> { + if self.config.load_policy == LoadPolicy::Recovery { + return Err(WalletStorageError::ReadOnlyRecoveryMode { operation }); + } + Ok(()) + } + + /// What the most recent [`load`](PlatformWalletPersistence::load) + /// tolerated instead of returning as an error. + /// + /// Per-load, not cumulative: each `load()` replaces the snapshot, so a + /// database restored from a backup and reloaded clean reports clean. A + /// `load()` that returns `Err` leaves it empty — the error is the + /// verdict there, not a partial tally. + /// [`load_unowned_identities`](Self::load_unowned_identities) *adds* + /// into the current snapshot, so its counts read as "since the last + /// `load()`". Reading does not clear. The point read + /// [`get_core_tx_record`](PlatformWalletPersistence::get_core_tx_record) + /// is the exception: its tolerated drift is logged and never tallied, + /// because one read per transaction folded into a per-load snapshot + /// would grow without bound and say nothing about the load. + /// + /// Under [`LoadPolicy::Strict`] a non-empty snapshot means only the + /// never-fatal sites fired — anything else would have failed the load. + /// + /// Reachable on the concrete type only: a caller holding + /// `Arc` cannot get here, since the + /// degradation surface is deliberately storage-specific rather than + /// part of the persistence trait. + pub fn last_load_degradation(&self) -> LoadDegradation { + self.last_load_degradation + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } + + /// Overwrite the degradation snapshot. `load()` calls this twice: once + /// to clear at entry, once with the finished tally. + fn replace_load_degradation(&self, degradation: LoadDegradation) { + *self + .last_load_degradation + .lock() + .unwrap_or_else(|p| p.into_inner()) = degradation; + } + + /// Fold another read's tally into the current snapshot, for the reads + /// that run outside `load()` and so must not reset it. + fn merge_load_degradation(&self, degradation: LoadDegradation) { + self.last_load_degradation + .lock() + .unwrap_or_else(|p| p.into_inner()) + .merge(degradation); + } + + /// `true` when the last `load()` tolerated at least one inconsistency. + pub fn is_degraded(&self) -> bool { + self.last_load_degradation + .lock() + .unwrap_or_else(|p| p.into_inner()) + .degraded + } + /// Take a manual online backup. `dest` may be a directory (auto- /// named `wallet-.db`) or a full file path (must not pre-exist). + /// + /// Allowed in [`LoadPolicy::Recovery`]: it only reads the source + /// database, and snapshotting before touching anything is the most + /// valuable thing a recovery-mode user can do. pub fn backup_to(&self, dest: &Path) -> Result { let resolved = if dest.is_dir() { dest.join(backup::manual_backup_filename()) @@ -245,11 +527,20 @@ impl SqlitePersister { /// /// # Cross-process rollback caveat /// - /// The pre-restore auto-backup is taken BEFORE the SQLite-native - /// `BEGIN EXCLUSIVE` that guards the restore body. Under concurrent - /// cross-process access the rollback point may therefore miss writes - /// a peer committed between the snapshot and the lock. Serializing - /// restore intent across processes is the caller's responsibility. + /// The pre-restore auto-backup is taken BEFORE the restore body's + /// exclusive SQLite lock, so under concurrent cross-process access the + /// rollback point may miss writes a peer committed in between. Callers + /// must serialize restore intent across processes. + /// + /// # Source trust + /// + /// Restore verifies SQLite integrity, wallet application identity, and + /// schema compatibility, but not backup provenance — and not content + /// invariants: a source holding two identities in one wallet's derivation + /// slot is restored as-is and surfaces as a failed `load()` afterwards, + /// because `store`'s write-path check never sees these bytes. Restore + /// trusts a valid source file as much as the live database; protect the + /// backup directory from untrusted replacement or modification. pub fn restore_from( dest_db_path: &Path, src_backup: &Path, @@ -264,7 +555,9 @@ impl SqlitePersister { /// 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`. + /// `auto_backup_dir`. Source validation does not authenticate backup + /// provenance; the source-trust warning on [`restore_from`](Self::restore_from) + /// applies here equally. pub fn restore_from_skip_backup( dest_db_path: &Path, src_backup: &Path, @@ -278,42 +571,134 @@ impl SqlitePersister { auto_backup_dir: Option<&Path>, skip_backup: bool, ) -> Result<(), WalletStorageError> { + let parent = dest_db_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + crate::parent_permissions::check_parent_perms(parent).map_err(|error| match error { + crate::parent_permissions::ParentPermissionsError::Io(source) => { + WalletStorageError::Io(source) + } + crate::parent_permissions::ParentPermissionsError::Insecure { ancestor, reason } => { + WalletStorageError::InsecureParentDir { ancestor, reason } + } + })?; + + // Refuse to overwrite a database a live persister in this process is + // still holding open: that handle's buffer/connection would silently + // diverge from the restored bytes. Canonicalize to match how `open()` + // registers the path (symlinks / `.`-segments resolve to one key); a + // not-yet-existing dest can't be open, so the fallback path is fine. + let dest_canonical = registry_key(dest_db_path, parent); + if is_path_open(&dest_canonical) { + return Err(WalletStorageError::AlreadyOpen { + path: dest_canonical, + }); + } 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. + // Open read-only just long enough to snapshot under auto_backup_dir. let dest_conn = crate::sqlite::conn::open_conn( dest_db_path, crate::sqlite::conn::Access::ReadOnly, )?; + let db_stem = backup::sanitize_db_stem(dest_db_path); run_auto_backup( &dest_conn, Some(dir), - BackupKind::PreRestore, + BackupKind::PreRestore { db_stem: &db_stem }, AutoBackupOperation::Restore, )?; drop(dest_conn); } - // No row-count fingerprint guards the snapshot → EXCLUSIVE - // window: `backup::restore_from` holds a SQLite-native `BEGIN - // EXCLUSIVE` over the whole restore body, so peers that race the - // snapshot are excluded from there on. A count fingerprint would - // miss in-place UPDATEs on single-row tables and give operators - // false confidence; callers needing a quiesced rollback point - // must serialize restore intent at the application layer. + // No row-count fingerprint guards the snapshot→EXCLUSIVE window: + // `backup::restore_from`'s exclusive SQLite lock covers the body, and a + // count would miss in-place UPDATEs and give false confidence. + // Callers needing a quiesced point serialize restore intent. backup::restore_from(dest_db_path, src_backup) } /// Apply retention to a directory of `wallet-*.db` (and/or /// `pre-*-*.db`) files. + /// + /// Blocked in [`LoadPolicy::Recovery`]: a user rescuing a damaged + /// database must not be shrinking their rollback set. pub fn prune_backups( &self, dir: &Path, policy: RetentionPolicy, ) -> Result { - backup::prune(dir, policy) + self.ensure_writable("prune_backups")?; + prune_backups_in(dir, policy) + } + + /// Read every identity that belongs to NO wallet, each already + /// carrying its own persisted public keys. + /// + /// # This is NOT delivered by `load()` + /// + /// [`load`](platform_wallet::changeset::PlatformWalletPersistence::load) + /// enumerates registered wallets and returns per-wallet state, so an + /// identity with no owning wallet appears in none of it. A host that + /// wants these must call this method explicitly — they are reachable + /// only here. On a store with no wallets at all, `load()` returns + /// nothing while this may still return identities. + /// + /// Deliberately inherent rather than part of + /// `PlatformWalletPersistence`: unowned identities are not wallet + /// state, so they get their own door instead of being folded into + /// some wallet's bucket. That also keeps them out of any + /// wallet-scoped changeset, which matters — an unowned identity that + /// entered a wallet's changeset would be claimed by that wallet on + /// the next flush via the `identities` orphan-promotion upsert. + /// + /// Write these back through a persister scoped to the all-zero + /// sentinel, the write counterpart of this read; flushing them under + /// a real wallet's scope is what promotes them. + /// + /// Keys are folded in exactly as `load()` does for wallet-owned + /// identities, so a returned `ManagedIdentity` is usable without a + /// second call. Tombstoned identities are omitted. + /// + /// # Errors + /// + /// [`WalletStorageError::UnownedIdentityHasRegistrationIndex`] when a + /// row claims both "no owning wallet" and a position within one. Under + /// [`LoadPolicy::Recovery`](crate::LoadPolicy) it is counted and the + /// identity is returned anyway. + /// + /// Its tally is *added* to the snapshot from the last `load()` rather + /// than replacing it, so [`last_load_degradation`](Self::last_load_degradation) + /// reads as "since the last `load()`". + pub fn load_unowned_identities( + &self, + ) -> Result, WalletStorageError> { + let conn = self.conn()?; + let ctx = LoadCtx::new(self.config.load_policy); + // The all-zero sentinel is the unowned scope: every reader it + // reaches maps it to a NULL `wallet_id` match. + let state = schema::identities::load_prekeyed(&conn, &UNOWNED_SCOPE, &ctx)?; + // An unowned identity carries no registration index, so it lands + // in `out_of_wallet_identities`. A row that somehow holds one is + // self-contradictory (an index is a position WITHIN a wallet). + use dpp::identity::accessors::IdentityGettersV0; + let mut unowned = state.out_of_wallet_identities; + if let Some(indexed) = state.wallet_identities.get(&UNOWNED_SCOPE) { + for (identity_index, managed) in indexed { + ctx.tolerate( + LoadSite::UnownedIdentityHasRegistrationIndex, + WalletStorageError::UnownedIdentityHasRegistrationIndex { + identity_id: managed.identity.id().to_buffer(), + identity_index: *identity_index, + }, + )?; + unowned.insert(managed.identity.id(), managed.clone()); + } + } + self.merge_load_degradation(ctx.degradation()); + Ok(unowned) } /// Cascade-delete every row owned by `wallet_id`. Takes a @@ -325,24 +710,33 @@ impl SqlitePersister { /// `--no-auto-backup` — call /// [`delete_wallet_skip_backup`](Self::delete_wallet_skip_backup). /// + /// # What "deleted" guarantees on disk + /// + /// The cascade runs under `PRAGMA secure_delete = ON`, so the pages it + /// frees are zeroed rather than merely unlinked and the wallet's row + /// content does not remain readable in the `.db`. Two limits are NOT + /// covered and are real: + /// + /// - Backups taken **before** this call still contain the wallet, by + /// design — including the pre-delete auto-backup this call takes. + /// Erasing a wallet from the live database does not erase it from a + /// rollback snapshot; remove those separately. + /// - The database file does not shrink. Zeroed pages stay in the file on + /// the freelist and are reused by later writes. + /// /// # Cross-process rollback caveat /// - /// The pre-delete auto-backup is taken BEFORE the SQLite-native - /// `BEGIN EXCLUSIVE` that guards the cascade. Under concurrent - /// cross-process access the rollback point may therefore miss writes - /// a peer committed between the snapshot and the lock. Serializing - /// delete intent across processes is the caller's responsibility. + /// The pre-delete auto-backup is taken BEFORE the cascade's + /// `BEGIN EXCLUSIVE`, so under concurrent cross-process access the + /// rollback point may miss writes a peer committed in between. Callers + /// must serialize delete intent across processes. /// /// # Racing stores /// - /// 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. + /// A `store(wallet_id, ...)` racing this call is **discarded** after + /// the delete commits — it may return `Ok(())` (Manual mode buffers + /// it) but a post-commit re-drain removes it. Synchronize at the + /// caller layer if you need other semantics. pub fn delete_wallet( &self, wallet_id: WalletId, @@ -370,24 +764,21 @@ impl SqlitePersister { wallet_id: WalletId, skip_backup: bool, ) -> Result { - // 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. + self.ensure_writable("delete_wallet")?; + // Take the conn mutex first so in-process `store()` blocks; + // cross-process peers are excluded by `BEGIN EXCLUSIVE` below. 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(). + // Drain the buffer so a later flush can't resurrect the wallet and + // so a buffer-only wallet still counts as existing. Held in + // `drained_slot` and consumed only after 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. + // Any pre-commit failure must restore the changeset so a delete + // that didn't happen doesn't lose pending writes. let restore_buffer = |slot: &std::cell::Cell>| { if let Some(cs) = slot.take() { if let Err(e) = self.buffer.restore(wallet_id, cs) { @@ -400,12 +791,19 @@ impl SqlitePersister { } }; + // Erase rather than merely unlink for the whole delete window. The + // cascade releases whole pages to the freelist, and the steady-state + // `FAST` setting does not clear those — only `ON` does. Raised here + // rather than around the cascade alone so no path out of the closure + // can skip the restore below. + raise_secure_delete_for_erase(&conn, wallet_id); + 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. + // Existence check before backup so we don't snapshot for an + // unknown wallet. let exists_pre_flush = conn .query_row( - "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT 1 FROM wallets WHERE wallet_id = ?1", rusqlite::params![wallet_id.as_slice()], |_| Ok(()), ) @@ -415,53 +813,71 @@ impl SqlitePersister { return Err(WalletStorageError::WalletNotFound { wallet_id }); } - // Test-only injector — 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. + // Test-only injector to fail the pre-flush below. #[cfg(any(test, feature = "__test-helpers"))] let primed_pre_flush_error = self.consume_primed_pre_flush_error(); - // 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. - // - // 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. + // Flush the drained buffer (its own EXCLUSIVE tx) BEFORE + // `run_auto_backup` so the snapshot includes pending writes; + // otherwise rollback-from-backup can't recover them. The backup + // must precede the cascade's `BEGIN EXCLUSIVE` because + // `Backup::new` deadlocks if the source holds an active write tx. + // Applying a changeset outside `store()` is safe here because + // `identities::apply` re-runs the slot check inside this very tx. 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)); + let pre_flush_tx = match conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive) + { + Ok(tx) => tx, + Err(e) => { + drained_slot.set(Some(cs)); + return Err(WalletStorageError::Sqlite(e)); + } + }; + match apply_changeset_to_tx(&pre_flush_tx, &wallet_id, &cs) { + Ok(()) => { + if let Err(e) = pre_flush_tx.commit() { + drained_slot.set(Some(cs)); + return Err(WalletStorageError::Sqlite(e)); + } + } + // An identity-slot collision means these pending + // writes can never be persisted, so keeping them + // would make the wallet undeletable — the one state + // a user most wants gone. Proceed with the delete; + // they name only this wallet, whose rows are about + // to go. They are dropped after the cascade COMMITS, + // not here: every step below can still fail, and a + // wallet that survives the failure keeps its staged + // writes. Every other constraint failure (FK, CHECK, + // UNIQUE, NOT NULL) describes corruption elsewhere + // in the schema and aborts the delete below. + Err( + e @ (WalletStorageError::IdentityIndexConflict { .. } + | WalletStorageError::WalletlessIdentityIndex { .. }), + ) => { + let _ = pre_flush_tx.rollback(); + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error_kind = e.error_kind_str(), + pending_field_count = populated_field_count(&cs), + "pending writes violate a storage invariant and cannot be persisted — the delete proceeds and discards them once it commits" + ); + drained_slot.set(Some(cs)); + } + Err(e) => { + let _ = pre_flush_tx.rollback(); + drained_slot.set(Some(cs)); + return Err(e); + } } } - // Concurrent-peer detection relies on the auto-backup taken - // before the cascade plus the SQLite-native `BEGIN EXCLUSIVE` - // below — not a row-count fingerprint, which an in-place - // UPDATE on a single-row table would evade. Pre-flushing - // before the backup ensures the snapshot captures every - // buffered write. let backup_path = if skip_backup { None } else { @@ -473,32 +889,54 @@ impl SqlitePersister { )? }; - // 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` during 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`. + // EXCLUSIVE for the cascade window excludes cross-process peers + // that the in-process conn mutex can't; they back off via + // `busy_timeout`. let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; - // Deleting the parent `wallet_metadata` row drives the whole - // cleanup: native `ON DELETE CASCADE` removes every FK-bearing - // per-wallet/per-identity table, and the AFTER DELETE triggers - // broom every wallet/identity-scoped `meta_*` row (parentless - // included). No per-table accounting is needed — the - // cascade-completeness test asserts no row survives. - crate::sqlite::schema::wallet_meta::delete(&tx, &wallet_id)?; + // Confirm the erasing mode is in force HERE, inside the transaction + // the cascade actually runs in — not merely where it was set. A + // pragma that failed to survive into this context fails silently, + // and the result would be a delete that reports success while + // leaving the wallet's pages legible. + match secure_delete_mode(&tx) { + Ok(SECURE_DELETE_ERASING_VALUE) => {} + other => tracing::warn!( + wallet_id = %hex::encode(wallet_id), + observed = ?other, + "the wallet cascade is running without the erasing secure_delete mode; \ + freed pages will retain deleted row content" + ), + } + + // Deleting the parent `wallets` row drives all cleanup: native + // `ON DELETE CASCADE` clears FK-bearing tables and AFTER DELETE + // triggers reap the `meta_*` rows (the completeness test + // asserts nothing survives). + crate::sqlite::schema::wallets::delete(&tx, &wallet_id)?; tx.commit()?; - // Commit succeeded — drop the original drained changeset. + // The wallet is gone, so anything still staged for it — a + // changeset the carve-out above could not persist — dies + // here rather than being restored to a buffer no flush could + // ever drain. drop(drained_slot.take()); - // 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!( + // Discard any changeset a Manual-mode store buffered during the + // delete window — the wallet is gone. + match self.buffer.take_for_flush(&wallet_id) { + Ok(Some(_late)) => tracing::warn!( wallet_id = %hex::encode(wallet_id), "discarded racing buffered changeset after delete_wallet commit" - ); + ), + Ok(None) => {} + // The delete itself already committed, so this still returns + // Ok — but a poisoned buffer mutex here is a signal every + // later store/flush on this persister will hit, so log it at + // the same level as the file's other LockPoisoned sites. + Err(e) => tracing::error!( + wallet_id = %hex::encode(wallet_id), + error_kind = e.error_kind_str(), + "buffer mutex poisoned draining racing changeset after delete_wallet commit" + ), } Ok(DeleteWalletReport { wallet_id, @@ -506,46 +944,43 @@ impl SqlitePersister { }) })(); + restore_secure_delete_steady_state(&conn, wallet_id); + if result.is_err() { restore_buffer(&drained_slot); } result } - /// Attempt to flush every dirty wallet, regardless of flush mode. + /// Flush every dirty wallet regardless of flush mode — the only way + /// `Manual` writes become durable, and the retry path for transient + /// `Immediate`-mode failures left in the buffer. "Durable" means across + /// application crash (WAL + `synchronous=NORMAL`); use + /// [`Synchronous::Full`](crate::Synchronous) for power-loss durability. /// - /// In `Manual` mode this is the only way pending writes become - /// durable. In `Immediate` mode the buffer is normally empty (each - /// `store` flushes inline) but a transient failure during `store` - /// leaves the changeset in the buffer — `commit_writes` is the - /// retry path that drains those leftovers. - /// - /// Continues past per-wallet failures instead of fails-fast. - /// 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. + /// Continues past per-wallet failures: each outcome lands on the + /// [`CommitReport`] (`succeeded` / `failed`), and `still_pending` fills + /// only when a `LockPoisoned` short-circuit skips the rest. Returns + /// `Err` only when enumerating the dirty set itself fails. pub fn commit_writes(&self) -> Result { self.commit_writes_inner() } fn commit_writes_inner(&self) -> Result { + // Before the report is built: a blocked commit is an `Err`, never a + // `CommitReport` a caller could mistake for "nothing to do". + self.ensure_writable("commit_writes") + .map_err(PersistenceError::from)?; + self.ensure_connection_usable() + .map_err(PersistenceError::from)?; let mut report = CommitReport { succeeded: Vec::new(), failed: Vec::new(), still_pending: Vec::new(), }; - // Even in `FlushMode::Immediate` the buffer can be non-empty: - // a transient failure during `store()` re-merges the changeset - // back into the buffer via `handle_flush_error`. The retry path - // — `commit_writes()` — has to drain that leftover regardless - // of flush mode, otherwise transient-failure data sits there - // until the next per-wallet `store` happens to retry it. + // Even in `Immediate` mode the buffer can be non-empty: a transient + // `store()` failure re-merges the changeset, and only this drains + // it regardless of flush mode. let dirty = self .buffer .dirty_wallets() @@ -555,10 +990,8 @@ impl SqlitePersister { 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. + // Mutex is gone; record this as failed and the rest as + // never-attempted instead of hammering them. report.failed.push((id, PersistenceError::LockPoisoned)); report.still_pending.extend(iter); return Ok(report); @@ -571,23 +1004,27 @@ impl SqlitePersister { /// Lock the write connection. pub(crate) fn conn(&self) -> Result, WalletStorageError> { - self.conn - .lock() - .map_err(|_| WalletStorageError::LockPoisoned) + match self.conn.lock() { + Ok(conn) => Ok(conn), + Err(_) => { + self.buffer.discard_all()?; + 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. + fn ensure_connection_usable(&self) -> Result<(), WalletStorageError> { + if self.conn.is_poisoned() { + self.buffer.discard_all()?; + return Err(WalletStorageError::LockPoisoned); + } + Ok(()) + } + + // The `__test-helpers` feature uses Cargo's `__` prefix convention: + // not public API, downstream MUST NOT enable it. + /// Test-only: borrow the write connection to seed rows or probe + /// non-public tables/pragmas. Downstream MUST NOT enable the feature. #[doc(hidden)] #[cfg(any(test, feature = "__test-helpers"))] pub fn lock_conn_for_test(&self) -> MutexGuard<'_, Connection> { @@ -603,50 +1040,79 @@ impl SqlitePersister { } fn flush_inner(&self, wallet_id: &WalletId) -> Result<(), PersistenceError> { + self.ensure_writable("flush") + .map_err(PersistenceError::from)?; + // Hold the connection across the take, the write AND the + // restore-on-failure. In between, the changeset is in neither + // the buffer nor the database, and a `store` probing that window + // would read a free slot that is not free. Taking the connection + // first denies it the window: `store` needs the same lock to + // check an identity write. Locking it also subsumes the + // `ensure_connection_usable` poison check. + let mut conn = self.conn().map_err(PersistenceError::from)?; + self.flush_locked(&mut conn, wallet_id) + } + + /// Drain and write `wallet_id`'s buffered changeset under a + /// connection the caller already holds. + /// + /// Split out of [`flush_inner`](Self::flush_inner) so an + /// `Immediate`-mode `store` can span its merge and its flush with + /// ONE guard: `self.conn()` is not reentrant, so a `store` holding + /// the connection cannot call `flush_inner`. + fn flush_locked( + &self, + conn: &mut Connection, + 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. + // Test-only injector: surface a primed failure without touching SQL. #[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) { + match write_changeset_in_one_tx(conn, 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( + /// Merge `cs` into `wallet_id`'s buffer, refusing it if its + /// identities would contradict the slots already taken. + /// + /// The check runs against the merged view — buffered plus incoming, + /// exactly what a flush would write — from inside the buffer's + /// critical section, so two callers racing one slot are serialized: + /// the second sees the first as the occupant and is refused here + /// rather than merging into a contradictory changeset that the flush + /// would drop whole. `conn` is `None` only when the changeset can't + /// claim a slot in the first place, which makes the probe moot. + fn merge_checked( &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(()) + conn: Option<&Connection>, + wallet_id: WalletId, + cs: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.buffer + .store_checked(wallet_id, cs, |buffered, incoming| { + let (Some(conn), Some(merged)) = (conn, merged_identities(buffered, incoming)) + else { + return Ok(()); + }; + schema::identities::check_index_conflicts(conn, &wallet_id, &merged) + }) + .map_err(PersistenceError::from) } /// Classify the failure: transient errors restore the buffer and - /// surface as `FlushRetryable`; everything else drops the - /// changeset and returns the original variant. - // - // TODO(qa): the fatal branch below covers `LockPoisoned`, but no - // end-to-end mutex-poison test exists (a panicking thread plus a join - // is hard to reproduce deterministically). It is verified by hand via - // `Mutex::lock` failure injection at the typed-error layer; anyone - // touching the classification policy or this branch must reconfirm - // by hand. + /// surface as `FlushRetryable`; everything else drops the changeset + /// and returns the original variant. fn handle_flush_error( &self, wallet_id: &WalletId, @@ -656,9 +1122,8 @@ impl SqlitePersister { 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. + // A failed restore loses the changeset — itself fatal, so + // surface it instead of the transient signal. if let Err(restore_err) = self.buffer.restore(*wallet_id, cs) { tracing::error!( wallet_id = %hex::encode(wallet_id), @@ -668,16 +1133,13 @@ impl SqlitePersister { ); return Err(PersistenceError::from(restore_err)); } - // Narrow the error to its rusqlite source — only - // `Sqlite(SqliteFailure(BUSY|LOCKED, _))` qualifies for - // surfacing as `FlushRetryable`. + // Narrow to the rusqlite source for `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. + // Defensive: "transient" but non-rusqlite source — + // surface raw rather than mislabel the source type. tracing::warn!( wallet_id = %hex::encode(wallet_id), error_kind = kind, @@ -704,16 +1166,13 @@ impl SqlitePersister { 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. + /// Test-only: arm a one-shot injection for the next `flush_inner`, + /// for tests that care only how the wrapper reacts to the error. #[doc(hidden)] #[cfg(any(test, feature = "__test-helpers"))] pub fn force_next_flush_to_fail(&self, err: WalletStorageError) { @@ -729,9 +1188,7 @@ impl SqlitePersister { } /// 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. + /// `delete_wallet`; fires only when there's a drained changeset to flush. #[doc(hidden)] #[cfg(any(test, feature = "__test-helpers"))] pub fn force_next_pre_flush_to_fail(&self, err: WalletStorageError) { @@ -749,9 +1206,39 @@ impl SqlitePersister { .take() } - /// Test-only: probe whether the wallet has a buffered changeset. - /// Used to assert the buffer survives a failed pre-flush without - /// consuming it. + /// Test-only: install a callback fired between `store()`'s buffer + /// merge and its flush. Same visibility rules as + /// [`lock_conn_for_test`](Self::lock_conn_for_test). + /// + /// Unlike [`force_next_flush_to_fail`](Self::force_next_flush_to_fail) / + /// [`force_next_pre_flush_to_fail`](Self::force_next_pre_flush_to_fail), + /// **this is not one-shot** — the callback stays armed and fires on + /// every subsequent `store()` call on this persister until overwritten + /// or cleared. A test that wants it to fire exactly once (the common + /// case) needs its own latch — see `release_at_store_seam` in + /// `tests/common` for the pattern. + #[doc(hidden)] + #[cfg(any(test, feature = "__test-helpers"))] + pub fn set_store_flush_seam_for_test(&self, seam: Arc) { + *self.store_flush_seam.lock().expect("store_flush_seam") = Some(seam); + } + + /// Clone the callback out before running it: a seam that re-enters + /// `store` would otherwise deadlock on this very mutex. + #[cfg(any(test, feature = "__test-helpers"))] + fn run_store_flush_seam(&self) { + let seam = self + .store_flush_seam + .lock() + .expect("store_flush_seam") + .clone(); + if let Some(seam) = seam { + seam(); + } + } + + /// Test-only: whether the wallet has a buffered changeset (asserts 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 { @@ -762,22 +1249,20 @@ impl SqlitePersister { } } -/// 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. +/// On drop of a `Manual`-mode persister with dirty wallets, log an error +/// so the silent-data-loss footgun surfaces. We do NOT auto-flush from +/// `Drop`: `flush_inner` can fail and `Drop` can't propagate, so swallowing +/// would be worse than a loud log. `Immediate` mode never trips this. impl Drop for SqlitePersister { fn drop(&mut self) { + // Release the path claim FIRST so it happens regardless of flush + // mode (the warning below early-returns for Immediate). + release_open_path(&self.registered_path); 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. + // `dirty_wallets` only fails on a poisoned buffer mutex; surface + // the lost state where we can. let dirty = match self.buffer.dirty_wallets() { Ok(d) => d, Err(e) => { @@ -792,11 +1277,9 @@ impl Drop for SqlitePersister { 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. + // `take_for_flush` drains the buffer — intentional in `Drop`: no + // future caller can observe it, and we need the changeset to count + // fields for the diagnostic. let total_fields: usize = dirty .iter() .filter_map(|id| { @@ -827,14 +1310,10 @@ impl PlatformWalletPersistence for SqlitePersister { // invitations, account pools, tracked asset locks, and // deferred-contact-crypto queue rows. // Do NOT attest WALLET_RESTORE (and therefore not provider restore): - // `load()` still reports `ClientStartState::wallets` in - // `LOAD_UNIMPLEMENTED`. Shielded state lives in a separate store. - // `CORE_SWEEP_REMOVAL`: the full contract — loser removal, the - // outpoint-keyed placeholder for a held input whose funding has not - // classified, releases by outpoint, and the finality-boundary - // collector — is implemented and documented in `core_state::apply`, - // `apply_sweep` and `collect_finalized_tombstones`. - PersistenceCapabilities::ATOMIC_CHANGESETS + // token balances and the DashPay overlay have no load readers, so a + // full restore remains lossy. Shielded viewing keys are native when + // compiled in; notes, nullifiers, and sync state use ShieldedStore. + let capabilities = PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) @@ -843,7 +1322,15 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::TRACKED_MASTERNODES) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) - .union(PersistenceCapabilities::DASHPAY_PAYMENTS) + .union(PersistenceCapabilities::DASHPAY_PAYMENTS); + #[cfg(feature = "shielded")] + { + capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS) + } + #[cfg(not(feature = "shielded"))] + { + capabilities + } } fn persist_tracked_masternodes( @@ -851,6 +1338,11 @@ impl PlatformWalletPersistence for SqlitePersister { network: dashcore::Network, records: &[platform_wallet::masternode::TrackedMasternode], ) -> Result<(), PersistenceError> { + // Whole-set semantics: `replace_all` DELETEs the network's rows + // before re-inserting, so an ungated call from a degraded + // in-memory view zeroes the set on the database being rescued. + self.ensure_writable("persist_tracked_masternodes") + .map_err(PersistenceError::from)?; let mut conn = self.conn().map_err(PersistenceError::from)?; let tx = conn .transaction() @@ -866,33 +1358,78 @@ impl PlatformWalletPersistence for SqlitePersister { network: dashcore::Network, ) -> Result, PersistenceError> { let conn = self.conn().map_err(PersistenceError::from)?; - schema::tracked_masternodes::load_all(&conn, network).map_err(PersistenceError::from) + let ctx = LoadCtx::new(self.config.load_policy); + schema::tracked_masternodes::load_all(&conn, network, &ctx).map_err(PersistenceError::from) } /// Merge `changeset` into the per-wallet buffer. /// /// 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). + /// - [`FlushMode::Immediate`]: on `Ok`, durable across application + /// crash — one transaction wraps every per-table apply (all-or- + /// nothing). A transient failure restores the buffer and surfaces + /// [`WalletStorageError::FlushRetryable`]. Use + /// [`Synchronous::Full`](crate::Synchronous) for power-loss durability. + /// - [`FlushMode::Manual`]: only merges into the buffer; durability + /// needs [`flush`](Self::flush) or + /// [`commit_writes`](Self::commit_writes). + /// + /// # Errors + /// + /// [`WalletStorageError::ReadOnlyRecoveryMode`] under + /// [`LoadPolicy::Recovery`](crate::LoadPolicy), raised before the + /// changeset is buffered so it cannot reach disk through a later flush. + /// + /// [`WalletStorageError::IdentityIndexConflict`] / + /// [`WalletStorageError::WalletlessIdentityIndex`] (kind `Constraint`) + /// when the identities sub-changeset would put two identities in one + /// wallet's derivation slot — whether the sitting occupant is on disk or + /// still buffered. The changeset is rejected whole and never reaches the + /// buffer. fn store( &self, wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { - self.buffer - .store(wallet_id, changeset) + // Refused at the door, ahead of `buffer.store`, so a recovery-mode + // changeset can never sit in the buffer waiting to leak out through + // some later write path. + self.ensure_writable("store") .map_err(PersistenceError::from)?; + self.ensure_connection_usable() + .map_err(PersistenceError::from)?; + // Validating BEFORE the changeset joins the shared per-wallet + // buffer makes the error name the write that caused it, and + // keeps a changeset another caller staged for the same wallet + // from being dropped as collateral of this one's rejection. match self.config.flush_mode { - FlushMode::Immediate => self.flush_inner(&wallet_id), - FlushMode::Manual => Ok(()), + // ONE connection guard spans the merge AND the flush. Every + // path that drains the buffer takes the same lock, so no + // other flush can own — and fatally drop — the changeset + // merged just above: the flush below reports the fate of + // exactly what this call staged, which is what the + // durability contract above promises. + FlushMode::Immediate => { + let mut conn = self.conn().map_err(PersistenceError::from)?; + self.merge_checked(Some(&conn), wallet_id, changeset)?; + #[cfg(any(test, feature = "__test-helpers"))] + self.run_store_flush_seam(); + self.flush_locked(&mut conn, &wallet_id) + } + // Nothing reaches disk here, so the connection is worth + // taking only for the identity-slot probe. + FlushMode::Manual => { + let conn = changeset + .identities + .is_some() + .then(|| self.conn()) + .transpose() + .map_err(PersistenceError::from)?; + self.merge_checked(conn.as_deref(), wallet_id, changeset)?; + #[cfg(any(test, feature = "__test-helpers"))] + self.run_store_flush_seam(); + Ok(()) + } } } @@ -902,27 +1439,41 @@ impl PlatformWalletPersistence for SqlitePersister { /// 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. + /// Populates `platform_addresses` and the keyless per-wallet `wallets` + /// payload (network, birth height, account manifest, core state, + /// identities, `Consumed`-filtered asset locks). Carries **no** `Wallet` + /// or key material — the manager rebuilds each wallet watch-only and + /// signs later on demand. + /// The `tracing::info!` summary reports `wallets_rehydrated`. + /// + /// # Load policy + /// + /// Under [`LoadPolicy::Strict`](crate::LoadPolicy) — the default — any + /// row that fails to decode, contradicts its typed columns, or cannot be + /// routed back to its account aborts the whole load: a corrupted wallet + /// is never handed back half-formed. Under + /// [`LoadPolicy::Recovery`](crate::LoadPolicy) those failures are logged + /// and counted on [`last_load_degradation`](Self::last_load_degradation) + /// instead, and the persister is read-only for the rest of its life. /// - /// 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. + /// Two sites degrade in **both** policies because their signal cannot + /// distinguish corruption from a healthy wallet: a used address whose + /// owner is not one of this wallet's funds accounts, and a restored + /// address that does not resolve against its account xpub. Both re-warm + /// on the next sync and the balance total is exact regardless. /// - /// **Query budget.** Constant w.r.t. wallet count: one `SELECT` over - /// `wallet_metadata` for the wallet-id list plus a fixed set of - /// grouped scans (sync state, addresses, platform-payment - /// registrations), not a per-wallet fan-out. + /// **Query budget.** Platform addresses load via grouped bulk scans + /// (constant), but the keyless per-wallet payload is a fan-out: one + /// id-list `SELECT` plus a fixed set of per-wallet reads for each wallet + /// (core state, identities, asset locks, contacts, identity keys, used + /// addresses). O(wallets) queries overall — acceptable for one-shot + /// startup, not the hot path. /// /// # Concurrency /// - /// 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. + /// Holds the connection mutex for the whole read, so concurrent + /// `store` / `flush` / `delete_wallet` block until it returns. Intended + /// for one-shot startup use, not the hot write path. /// /// # Examples /// @@ -942,6 +1493,11 @@ impl PlatformWalletPersistence for SqlitePersister { /// .as_nanos() /// )); /// std::fs::create_dir_all(&dir).unwrap(); + /// #[cfg(unix)] + /// { + /// use std::os::unix::fs::PermissionsExt; + /// std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + /// } /// let db_path = dir.join("wallets.db"); /// /// let config = SqlitePersisterConfig::new(&db_path); @@ -960,23 +1516,38 @@ impl PlatformWalletPersistence for SqlitePersister { /// # } /// ``` fn load(&self) -> Result { - // TODO: repopulate ClientStartState.wallets. The - // identity/contacts/asset-lock readers exist, but the - // `Wallet::from_persisted` wiring lands with the rehydration work; - // until then `load()` only rebuilds `platform_addresses` and emits - // a structured-log summary so operators see the gap. let conn = self.conn().map_err(PersistenceError::from)?; + let ctx = LoadCtx::new(self.config.load_policy); + // Cleared up front so a failed load never leaves the previous load's + // snapshot behind, masquerading as this one's verdict. + self.replace_load_degradation(LoadDegradation::default()); let mut state = ClientStartState::default(); + #[cfg(feature = "shielded")] + { + state.shielded.viewing_keys = schema::shielded_viewing_keys::load_all(&conn, &ctx) + .map_err(PersistenceError::from)?; + } + 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 { - // Omit a wallet that carries no platform state at all: no - // per-account registrations, no addresses, and all sync - // watermarks zero. Such a wallet contributes nothing to a - // restored provider. + // Wallets whose platform-address rows could not be read. They are + // already counted here, so the loop below skips them rather than + // rebuilding a wallet whose platform balance would be understated. + let mut unreadable: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for (wallet_id, entry) in addrs_all { + let (addrs, count) = match entry { + Ok(entry) => entry, + Err(original) => { + degrade_whole_wallet(&ctx, wallet_id, &original) + .map_err(|_| PersistenceError::from(original))?; + unreadable.insert(wallet_id); + continue; + } + }; + // Skip a wallet with no platform state at all (no addresses, + // no registrations, all sync watermarks zero). if count > 0 || !addrs.per_account.is_empty() || addrs.sync_height > 0 @@ -988,14 +1559,54 @@ impl PlatformWalletPersistence for SqlitePersister { } } + // Per-wallet keyless rehydration payload; the manager rebuilds each + // wallet watch-only and derives signing keys later on demand. + let wallet_ids = schema::wallets::list_ids(&conn).map_err(PersistenceError::from)?; + let wallets_seen = wallet_ids.len(); + for wallet_id in wallet_ids { + if unreadable.contains(&wallet_id) { + continue; + } + match load_one_wallet(&conn, wallet_id, &ctx) { + Ok(wallet_state) => { + state.wallets.insert(wallet_id, wallet_state); + } + // The isolation boundary. One wallet's failure is recorded + // against that wallet and the walk continues; under Strict + // `tolerate_at` returns, and the ORIGINAL error propagates + // rather than the boundary's own wrapper, so a caller + // matching on a specific cause still sees it. + Err(original) => { + let cause = wallet_storage_kind(&original); + record_wallet_degradation(&ctx, wallet_id, cause, &original) + .map_err(|_| original)?; + } + } + } + let wallets_rehydrated = state.wallets.len(); + #[cfg(feature = "shielded")] + let shielded_viewing_keys_loaded = state.shielded.viewing_keys.len(); + #[cfg(not(feature = "shielded"))] + let shielded_viewing_keys_loaded = 0usize; + + ctx.add_unimplemented_rows( + count_unimplemented_rows(&conn).map_err(PersistenceError::from)?, + ); + let degradation = ctx.degradation(); tracing::info!( wallets_seen, addresses_loaded, - wallets_rehydrated = 0usize, - wallets_pending_rehydration = wallets_seen, + wallets_rehydrated, + shielded_viewing_keys_loaded, + wallets_pending_rehydration = 0usize, + degraded = degradation.degraded, + degraded_total = degradation.total, + degraded_by_site = ?degradation.by_site, unimplemented = ?LOAD_UNIMPLEMENTED, + unimplemented_rows = degradation.unimplemented_rows, "load() summary" ); + self.replace_load_degradation(degradation); Ok(state) } @@ -1008,7 +1619,12 @@ impl PlatformWalletPersistence for SqlitePersister { PersistenceError, > { let conn = self.conn().map_err(PersistenceError::from)?; - schema::core_state::get_tx_record(&conn, &wallet_id, txid).map_err(PersistenceError::from) + // Tally deliberately dropped — see `last_load_degradation`. The + // policy still decides whether drift is fatal, and `tolerate` still + // logs it. + let ctx = LoadCtx::new(self.config.load_policy); + schema::core_state::get_tx_record(&conn, &wallet_id, txid, &ctx) + .map_err(PersistenceError::from) } /// Served from the `dpns_name_states` table this persister already @@ -1040,34 +1656,269 @@ impl PlatformWalletPersistence for SqlitePersister { } } -// ----- 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. +/// Count of top-level changeset slots carrying data, for the +/// `restored_field_count` / `dropped_field_count` / `pending_field_count` +/// tracing fields. Computed +/// from the public fields so no storage-only helper leaks into the +/// `rs-platform-wallet` 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() + // Single source of truth with the version-domain mapping: each populated + // field is exactly one touched domain. + schema::versions::touched_domains(cs).len() +} + +/// Total rows sitting in the tables `load()` has no reader for. +/// +/// Informational, not a degradation: the data is intact and a future +/// reader can pick it up — it simply is not in this `ClientStartState`. +/// One statement, not one per table: `tc_p4_012` holds `load()`'s +/// wallet-count-independent statement count to a small constant. +/// Rehydrate one wallet, or fail without touching any other. +/// +/// Extracted from `load()`'s loop so the loop has somewhere to put a +/// boundary: every `?` in here ends this wallet, not the file. +fn load_one_wallet( + conn: &Connection, + wallet_id: WalletId, + ctx: &LoadCtx, +) -> Result { + let (network_str, birth_height) = schema::wallets::fetch(conn, &wallet_id) + .map_err(PersistenceError::from)? + .ok_or_else(|| { + PersistenceError::backend(format!( + "wallets row vanished mid-load for {}", + hex::encode(wallet_id) + )) + })?; + let network = schema::wallets::parse_network(&network_str).ok_or_else(|| { + PersistenceError::backend(format!( + "unknown persisted network {:?} for wallet {}", + network_str, + hex::encode(wallet_id) + )) + })?; + + let account_manifest = + schema::accounts::load_state(conn, &wallet_id, ctx).map_err(PersistenceError::from)?; + let (core_state, utxo_accounts) = + schema::core_state::load_state(conn, &wallet_id, network, ctx) + .map_err(PersistenceError::from)?; + // Pre-keyed rehydration: each `ManagedIdentity` leaves the loader + // already carrying its own public keys + contact state (matching + // the FFI persister), so signing works immediately post-load + // without a key sync. `ClientWalletStartState.contacts` / + // `.identity_keys` stay empty — nothing is layered on afterwards. + let identity_manager = + schema::identities::load_prekeyed(conn, &wallet_id, ctx).map_err(PersistenceError::from)?; + let unused_asset_locks = schema::asset_locks::load_unconsumed(conn, &wallet_id, ctx) + .map_err(PersistenceError::from)?; + // Used addresses drive the reuse guard: a used-then-emptied + // address must never be handed back as a fresh receive address, + // and must come back used on ITS OWN account so it is never + // re-issued as a fresh receive address from that account. Union + // the verbatim `core_address_pool` used-set (known owner) with the + // `core_utxos`-derived set (spent + unspent; owner resolved per + // script, `None` when no pool row covers it). The guard is + // monotonic, so a mixed store — historical UTXOs plus a later + // partial pool snapshot that never enumerates them — must surface + // both; neither source may shadow the other. Keyed by address; the + // pool source is authoritative on owner, so a `None` from the + // `core_utxos` source never overrides a resolved pool owner. Two + // resolved-but-disagreeing owners for one script means the store + // cannot say which account may re-issue the address, so the + // policy decides: strict aborts, recovery keeps the pool owner. + let used_core_addresses = { + let mut union: std::collections::HashMap< + dashcore::Address, + Option, + > = std::collections::HashMap::new(); + let pool = schema::core_pool::load_used_addresses_with_ctx(conn, &wallet_id, network, ctx) + .map_err(PersistenceError::from)?; + for (addr, owner) in pool { + union.entry(addr).or_insert(Some(owner)); + } + let utxo = schema::core_state::load_used_addresses_with_ctx(conn, &wallet_id, network, ctx) + .map_err(PersistenceError::from)?; + for (addr, owner) in utxo { + match union.entry(addr) { + std::collections::hash_map::Entry::Occupied(existing) => { + if let (Some(pool_owner), Some(utxo_owner)) = (existing.get(), &owner) { + if pool_owner != utxo_owner { + let conflict = WalletStorageError::UsedAddressOwnerConflict { + address: existing.key().to_string(), + pool_owner: format!( + "{}[{}]", + pool_owner.account_type, pool_owner.account_index + ), + utxo_owner: format!( + "{}[{}]", + utxo_owner.account_type, utxo_owner.account_index + ), + }; + ctx.tolerate(LoadSite::UsedAddressOwnerConflict, conflict) + .map_err(PersistenceError::from)?; + } + } + } + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(owner); + } + } + } + union + }; + + // Reconstruct a populated `ManagedWalletInfo` from typed rows: + // rebuild the wallet watch-only from the manifest, then layer the + // persisted core-state projection (UTXOs, sync watermarks, + // chainlock, used-address pool depth) onto it. The manager consumes + // this directly — the old skeleton + core_state replay fallback is + // gone. + let wallet = if account_manifest.is_empty() { + // No accounts of any kind for this wallet. An empty manifest + // is NOT necessarily an orphaned row: a platform-only wallet — a + // Platform identity plus contacts, with no core accounts — + // legitimately has one. Register it as an external-signable + // placeholder (empty AccountCollection) that still carries its + // platform-side state (identities, contacts); the manager + // registers it like any other wallet. The genuinely-orphaned + // case (a crash between the wallet-row write and the first + // account write) also lands here and is harmless — it rehydrates + // as an empty wallet. + // + // TODO(product decision needed, task #14): the orphaned variant + // leaves a permanently empty manifest. It is not corrupted or + // lost, but there is no recovery path today: no re-registration + // flow, no eviction, no surfacing to the user. Open question: + // does this need one (a TTL-based cleanup, a re-registration + // entry point, or a surfaced "orphaned wallet" diagnostic), or is + // register-empty-forever acceptable? Awaiting product decision; + // not addressed here. + key_wallet::wallet::Wallet::new_external_signable( + network, + wallet_id, + key_wallet::account::account_collection::AccountCollection::new(), + ) + } else { + build_wallet(network, wallet_id, &account_manifest).map_err(|e| { + PersistenceError::backend(format!( + "watch-only wallet rebuild failed for {}: {e}", + hex::encode(wallet_id) + )) + })? + }; + // TODO(insert-wallet-id-recompute): confirm whether key_wallet's + // insert_wallet recomputes wallet_id — see PR's existing Deferred + // #3992 note. Both construction paths above hand it the persisted + // id; if the manager derives its own instead, a rehydrated wallet + // could be filed under an id that no longer matches its rows. + // Answering it needs the key-wallet crate, not this repo. + let mut wallet_info = key_wallet::wallet::managed_wallet_info::ManagedWalletInfo::from_wallet( + &wallet, + birth_height, + ); + // Provider key-material accounts hold no funds, so only the ECDSA + // half feeds the UTXO/balance projection here. The platform-node + // pre-derived-key pool is restored separately below. + apply_persisted_core_state( + &mut wallet_info, + &account_manifest.ecdsa, + &core_state, + &utxo_accounts, + &used_core_addresses, + ctx, + ) + .map_err(|e| { + PersistenceError::backend(format!( + "core-state rehydration failed for {}: {e}", + hex::encode(wallet_id) + )) + })?; + if account_manifest + .provider + .iter() + .any(|entry| entry.account_type == key_wallet::account::AccountType::ProviderPlatformKeys) + { + restore_provider_platform_node_pool(&mut wallet_info, conn, &wallet_id, network, ctx) + .map_err(|e| { + PersistenceError::backend(format!( + "platform-node pool rehydration failed for {}: {e}", + hex::encode(wallet_id) + )) + })?; + } + Ok(platform_wallet::changeset::ClientWalletStartState { + wallet, + wallet_info, + identity_manager, + unused_asset_locks, + }) +} + +/// Count one wallet's whole loss and attribute it, or return so the caller +/// can propagate its own error under `Strict`. +/// +/// The returned error is discarded by every caller: it exists only to say +/// "Strict", because the caller holds a better error than this one — the +/// original cause, in the type its own signature promises. +fn record_wallet_degradation( + ctx: &LoadCtx, + wallet_id: WalletId, + cause: &'static str, + original: &dyn std::fmt::Display, +) -> Result<(), WalletStorageError> { + ctx.tolerate_at( + LoadSite::WalletRehydration, + crate::sqlite::load_ctx::SiteCoords { + wallet_id: Some(wallet_id), + account_type: &"wallet", + affected: 1, + detail: Some(&cause), + }, + WalletStorageError::WalletRehydrationFailed { + wallet_id, + cause: original.to_string(), + }, + )?; + ctx.note_wallet_degraded(wallet_id, cause); + Ok(()) +} + +/// [`record_wallet_degradation`] for a failure that is already a typed +/// storage error, so its own kind tag is the cause. +fn degrade_whole_wallet( + ctx: &LoadCtx, + wallet_id: WalletId, + original: &WalletStorageError, +) -> Result<(), WalletStorageError> { + record_wallet_degradation(ctx, wallet_id, original.error_kind_str(), original) +} + +/// The kind tag of the typed storage error inside a persistence error. +/// +/// The boundary attributes a dropped wallet to its cause, and the cause is +/// more useful than the boundary's own name: a caller wants `address_decode`, +/// not `wallet_rehydration_failed`. +fn wallet_storage_kind(err: &PersistenceError) -> &'static str { + match err { + PersistenceError::Backend { source, .. } => source + .downcast_ref::() + .map(WalletStorageError::error_kind_str) + .unwrap_or("backend"), + PersistenceError::LockPoisoned => "lock_poisoned", + } +} + +fn count_unimplemented_rows(conn: &Connection) -> Result { + let sum = LOAD_UNIMPLEMENTED_TABLES + .iter() + .map(|table| format!("(SELECT COUNT(*) FROM {table})")) + .collect::>() + .join(" + "); + let rows: i64 = conn.query_row(&format!("SELECT {sum}"), [], |row| row.get(0))?; + // Saturating rather than `safe_cast`: a cosmetic row count must never + // fail the load it is only describing. + Ok(u32::try_from(rows).unwrap_or(u32::MAX)) } fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageError> { @@ -1076,10 +1927,8 @@ fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageEr 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. + // `journal_mode` Memory/Off keeps no on-disk rollback journal, making + // a wallet DB crash-unsafe — reject loudly. match config.journal_mode { crate::sqlite::config::JournalMode::Memory => { return Err(WalletStorageError::ConfigInvalid { @@ -1093,10 +1942,17 @@ fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageEr } _ => {} } - // `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. + // Recovery mode is for damaged databases, and `open()` still migrates. + // Without a pre-migration auto-backup there is nothing to roll back to + // when the migration makes the damage worse, so refuse the combination + // instead of letting the rescue attempt burn the only copy. + if config.load_policy == LoadPolicy::Recovery && config.auto_backup_dir.is_none() { + return Err(WalletStorageError::AutoBackupDisabled { + operation: AutoBackupOperation::OpenMigration, + }); + } + // `busy_timeout=0` makes contended writers fail-fast with BUSY; + // warn (not reject) since a few tests legitimately want that. if config.busy_timeout.is_zero() { tracing::warn!( "SqlitePersisterConfig.busy_timeout=0; contended writers will return BUSY \ @@ -1106,14 +1962,139 @@ fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageEr Ok(()) } +/// Switch `conn` to the erasing `secure_delete` mode for a wallet cascade. +/// +/// A failure is logged and tolerated rather than aborting the delete: the user +/// asked for the wallet to be gone, and refusing to remove it because the file +/// cannot be scrubbed leaves them strictly worse off. The residue that survives +/// is the same residue the steady-state mode already leaves. +fn raise_secure_delete_for_erase(conn: &Connection, wallet_id: WalletId) { + set_secure_delete( + conn, + wallet_id, + SECURE_DELETE_ERASING, + SECURE_DELETE_ERASING_VALUE, + "could not raise secure_delete for the wallet cascade; freed pages may retain deleted row content", + ); +} + +/// Return `conn` to the steady-state `secure_delete` mode after a cascade. +/// +/// A failure here leaves the connection MORE aggressive than configured, never +/// less, so it costs I/O rather than confidentiality — logged and tolerated. +fn restore_secure_delete_steady_state(conn: &Connection, wallet_id: WalletId) { + set_secure_delete( + conn, + wallet_id, + SECURE_DELETE_STEADY_STATE, + SECURE_DELETE_STEADY_STATE_VALUE, + "could not restore secure_delete after the wallet cascade; later writes pay full erase cost", + ); +} + +/// Set `secure_delete` to `mode` and confirm it reads back as `expected`. +/// +/// `pragma_update` does not error when a setting fails to take, so the +/// read-back is the only thing standing between a silent no-op and a guarantee +/// the crate believes it has. It compares the exact value rather than "not +/// off": `ON` and `FAST` are distinct modes, and accepting either would let a +/// failed restore look like a successful one. +/// +/// Logged and tolerated rather than fatal. Both callers bracket a delete the +/// user asked for, and refusing to remove a wallet because the file cannot be +/// scrubbed leaves them strictly worse off than removing it imperfectly. +fn set_secure_delete( + conn: &Connection, + wallet_id: WalletId, + mode: &str, + expected: i64, + failure_message: &'static str, +) { + let outcome = conn + .pragma_update(None, "secure_delete", mode) + .map_err(WalletStorageError::Sqlite) + .and_then(|()| secure_delete_mode(conn)); + match outcome { + Ok(actual) if actual == expected => {} + Ok(actual) => tracing::warn!( + wallet_id = %hex::encode(wallet_id), + requested = mode, + actual, + failure_message + ), + Err(e) => tracing::warn!( + wallet_id = %hex::encode(wallet_id), + requested = mode, + error = %e, + failure_message + ), + } +} + +/// Steady-state `secure_delete` mode: zero freed row content within pages that +/// are being rewritten anyway, without paying to scrub pages released to the +/// freelist on every ordinary write. +const SECURE_DELETE_STEADY_STATE: &str = "FAST"; +/// What [`SECURE_DELETE_STEADY_STATE`] reads back as. SQLite reports the mode +/// numerically and the three values are distinct — `0` off, `1` ON, `2` FAST — +/// so a read-back that only checked for nonzero would accept `ON` where `FAST` +/// was asked for, and, worse, would accept a failed restore after a cascade. +const SECURE_DELETE_STEADY_STATE_VALUE: i64 = 2; +/// `secure_delete` mode for a wallet cascade, which releases whole pages that +/// `FAST` leaves intact. Deletion is rare, explicit and user-initiated, so it +/// can afford the I/O that every ordinary write cannot. +/// +/// Measured on this schema: deleting a wallet whose rows span whole pages +/// leaves the row content fully legible under `off`, PARTLY legible under +/// `FAST` (it clears only the part of a page it was rewriting anyway), and not +/// at all under `ON`. +const SECURE_DELETE_ERASING: &str = "ON"; +/// What [`SECURE_DELETE_ERASING`] reads back as. +const SECURE_DELETE_ERASING_VALUE: i64 = 1; + +/// Read the connection's `secure_delete` mode back. +/// +/// Must be issued on the connection that set it: the setting is per-connection, +/// and a fresh handle reports the build default rather than any value another +/// handle put in force. +fn secure_delete_mode(conn: &Connection) -> Result { + Ok(conn.pragma_query_value(None, "secure_delete", |row| row.get(0))?) +} + 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. + // `foreign_keys` is enabled + read-back-asserted in `open_conn`. conn.pragma_update(None, "journal_mode", config.journal_mode.pragma_value())?; + // Read `journal_mode` back: `pragma_update` doesn't error when SQLite + // silently falls back (e.g. WAL→DELETE on FUSE), which with + // synchronous=NORMAL risks corruption on power loss. + let applied_journal: String = + conn.pragma_query_value(None, "journal_mode", |row| row.get(0))?; + if !applied_journal.eq_ignore_ascii_case(config.journal_mode.pragma_value()) { + return Err(WalletStorageError::JournalModeNotApplied { + requested: config.journal_mode.pragma_value(), + actual: applied_journal, + }); + } conn.pragma_update(None, "synchronous", config.synchronous.pragma_value())?; + // Freed pages otherwise keep their content, so a deleted wallet's + // addresses, scripts, keys and contact data stay readable in the file — and + // `Backup` copies pages, freelist included, into every later snapshot. + // `FAST` zeroes freed content within a page already being rewritten, which + // is the right steady-state cost; `delete_wallet` raises it to `ON` for the + // cascade, where whole pages are released and only `ON` clears them. + conn.pragma_update(None, "secure_delete", SECURE_DELETE_STEADY_STATE)?; + // Read back like `journal_mode`: `pragma_update` does not error when the + // setting does not take, and a silent `0` here is an at-rest guarantee the + // crate documents and does not have. + let applied_secure_delete = secure_delete_mode(conn)?; + if applied_secure_delete != SECURE_DELETE_STEADY_STATE_VALUE { + return Err(WalletStorageError::SecureDeleteNotApplied { + actual: applied_secure_delete, + }); + } let ms = safe_cast::u64_to_i64( "busy_timeout_ms", u64::try_from(config.busy_timeout.as_millis()).unwrap_or(i64::MAX as u64), @@ -1122,24 +2103,67 @@ fn apply_pragmas( 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 without re-opening the connection. +/// The identity view a flush would write once `incoming` merges into +/// `buffered` — the state the slot check has to judge. +/// +/// Built with the same `Merge` impl the buffer uses, so the check sees +/// what `apply` will see and not an approximation of it. `None` when +/// `incoming` carries no identities. +fn merged_identities( + buffered: Option<&PlatformWalletChangeSet>, + incoming: &PlatformWalletChangeSet, +) -> Option { + let incoming = incoming.identities.clone()?; + let Some(mut merged) = buffered.and_then(|cs| cs.identities.clone()) else { + return Some(incoming); + }; + merged.merge(incoming); + Some(merged) +} + +/// 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. Takes the +/// already-locked connection so the caller can span the take/write +/// window with a single lock. +fn write_changeset_in_one_tx( + conn: &mut Connection, + wallet_id: &WalletId, + cs: &PlatformWalletChangeSet, +) -> Result<(), WalletStorageError> { + let tx = conn.transaction()?; + apply_changeset_to_tx(&tx, wallet_id, cs)?; + tx.commit()?; + Ok(()) +} + +/// Apply every populated sub-changeset of `cs` against `tx` without +/// committing (caller owns the tx). Separate from +/// `write_changeset_in_one_tx` so `delete_wallet_inner` can flush a drained +/// buffer into its own pre-delete tx. 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)?; + schema::wallets::upsert(tx, wallet_id, meta)?; } if !cs.account_registrations.is_empty() { schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?; } + if !cs.provider_key_account_registrations.is_empty() { + schema::accounts::apply_provider_registrations( + tx, + wallet_id, + &cs.provider_key_account_registrations, + )?; + } + // Pools land before core so the UTXO writer can attribute each outpoint + // to its owning account by matching the outpoint's script against a + // freshly-written `core_address_pool` row. if !cs.account_address_pools.is_empty() { - schema::accounts::apply_pools(tx, wallet_id, &cs.account_address_pools)?; + schema::core_pool::apply_pools(tx, wallet_id, &cs.account_address_pools)?; } if !cs.pending_contact_crypto_added.is_empty() || !cs.pending_contact_crypto_cleared.is_empty() { @@ -1153,6 +2177,10 @@ fn apply_changeset_to_tx( if let Some(core) = cs.core.as_ref() { schema::core_state::apply(tx, wallet_id, core)?; } + #[cfg(feature = "shielded")] + if let Some(shielded) = cs.shielded.as_ref() { + schema::shielded_viewing_keys::apply(tx, wallet_id, shielded)?; + } if let Some(identities) = cs.identities.as_ref() { schema::identities::apply(tx, wallet_id, identities)?; } @@ -1174,6 +2202,9 @@ fn apply_changeset_to_tx( if let Some(dpns_name_states) = cs.dpns_name_states.as_ref() { schema::dpns_name_states::apply(tx, wallet_id, dpns_name_states)?; } + if let Some(scan_state) = cs.identity_scan_state.as_ref() { + schema::identity_scan_states::apply(tx, wallet_id, scan_state)?; + } if let Some(balances) = cs.token_balances.as_ref() { schema::token_balances::apply(tx, wallet_id, balances)?; } @@ -1185,6 +2216,9 @@ fn apply_changeset_to_tx( cs.dashpay_payments_overlay.as_ref(), )?; } + // Bump each touched domain's version inside this same tx so a domain's + // cache-invalidation marker commits atomically with its data. + schema::versions::bump_touched_domains(tx, wallet_id, cs)?; Ok(()) } @@ -1195,7 +2229,7 @@ fn apply_changeset_to_tx( pub(crate) fn run_auto_backup( src_conn: &Connection, auto_backup_dir: Option<&Path>, - kind: BackupKind, + kind: BackupKind<'_>, operation: AutoBackupOperation, ) -> Result, WalletStorageError> { let Some(dir) = auto_backup_dir else { @@ -1208,20 +2242,62 @@ pub(crate) fn run_auto_backup( } fn ensure_dir(dir: &Path) -> Result<(), WalletStorageError> { + #[cfg(unix)] + let mut missing_components = Vec::new(); if !dir.exists() { - std::fs::create_dir_all(dir).map_err(|source| { + #[cfg(unix)] + { + let mut component = dir; + while !component.exists() && !component.as_os_str().is_empty() { + missing_components.push(component.to_path_buf()); + let Some(parent) = component.parent() else { + break; + }; + component = parent; + } + } + #[cfg(unix)] + let create_result = { + use std::os::unix::fs::DirBuilderExt; + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true).mode(0o700).create(dir) + }; + #[cfg(not(unix))] + let create_result = std::fs::create_dir_all(dir); + create_result.map_err(|source| WalletStorageError::AutoBackupDirUnwritable { + dir: dir.to_path_buf(), + source, + })?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if missing_components.is_empty() { + missing_components.push(dir.to_path_buf()); + } + for component in missing_components { + std::fs::set_permissions(&component, std::fs::Permissions::from_mode(0o700)).map_err( + |source| WalletStorageError::AutoBackupDirUnwritable { + dir: component, + source, + }, + )?; + } + } + crate::parent_permissions::check_parent_perms(dir).map_err(|error| match error { + crate::parent_permissions::ParentPermissionsError::Io(source) => { WalletStorageError::AutoBackupDirUnwritable { dir: dir.to_path_buf(), source, } - })?; - } - // Best-effort writability probe via `NamedTempFile` (unguessable - // name, no race against concurrent persister opens). This is TOCTOU - // by construction — the dir CAN flip to unwritable between the probe - // and `backup::run_to` below — but the real write 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. + } + crate::parent_permissions::ParentPermissionsError::Insecure { ancestor, reason } => { + WalletStorageError::InsecureParentDir { ancestor, reason } + } + })?; + // Fast-fail writability probe. TOCTOU by construction (the dir can flip + // before `run_to`), but the real write has its own error path, so the + // worst case is a later typed error instead of this early one. match tempfile::NamedTempFile::new_in(dir) { Ok(_probe) => Ok(()), Err(source) => Err(WalletStorageError::AutoBackupDirUnwritable { @@ -1261,3 +2337,132 @@ fn current_schema_version(conn: &Connection) -> Result, WalletStorag .flatten(); Ok(row.map(|v| v as i32)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every table in the migrated schema is accounted for: rehydrated by + /// `load()`, reachable through a dedicated production API, listed as + /// unimplemented, or schema infrastructure. A table in none of those + /// lists is state this crate persists and never reports — which is + /// exactly how `pending_contact_crypto` and `invitations` stayed + /// invisible while `unimplemented_rows` reported zero. + /// + /// The catalogue is read from `sqlite_master` rather than scanned out of + /// the migration text, because DDL text lies about `RENAME` and `DROP` + /// and this schema does several of both. + #[test] + fn every_table_in_the_schema_is_accounted_for() { + // Rehydrated into `ClientStartState` by `load()`. + const REHYDRATED_BY_LOAD: &[&str] = &[ + "account_registrations", + "asset_locks", + "contacts", + "core_address_pool", + "core_instant_locks", + "core_sync_state", + "core_transactions", + "core_utxos", + "identities", + "identity_keys", + "identity_scan_failed_indices", + "identity_scan_states", + "ignored_senders", + "platform_address_sync", + "platform_addresses", + "wallets", + ]; + // Not rehydrated by `load()`, but read on demand by a production + // entry point, so the state is reachable rather than abandoned. + const READ_BY_A_DEDICATED_API: &[&str] = &[ + "dpns_name_states", // get_dpns_name_state + "meta_contact", // the kv object store + "meta_data_versions", // schema::versions + "meta_global", // the kv object store + "meta_identity", // the kv object store + "meta_platform_address", // the kv object store + "meta_store_generation", // schema::versions + "meta_token", // the kv object store + "meta_wallet", // the kv object store + "tracked_masternodes", // load_tracked_masternodes + ]; + const INFRASTRUCTURE: &[&str] = &["refinery_schema_history"]; + // `load()` rehydrates these only with the `shielded` feature on, so + // the classification follows the build rather than claiming one. + #[cfg(feature = "shielded")] + const FEATURE_GATED: &[&str] = &["shielded_viewing_keys"]; + #[cfg(not(feature = "shielded"))] + const FEATURE_GATED: &[&str] = &[]; + #[cfg(feature = "shielded")] + const NOT_REHYDRATED_WITHOUT_FEATURE: &[&str] = &[]; + #[cfg(not(feature = "shielded"))] + const NOT_REHYDRATED_WITHOUT_FEATURE: &[&str] = &["shielded_viewing_keys"]; + + let mut conn = Connection::open_in_memory().expect("in-memory db"); + crate::sqlite::migrations::run(&mut conn).expect("migrate"); + let tables: Vec = { + let mut stmt = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' \ + AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .expect("read the catalogue"); + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .expect("read the catalogue") + .collect::, _>>() + .expect("read the catalogue"); + rows + }; + assert!( + tables.len() > 20, + "the catalogue probe returned {} tables, which cannot be right — \ + a probe that finds nothing proves nothing", + tables.len() + ); + + let unaccounted: Vec<&String> = tables + .iter() + .filter(|table| { + !REHYDRATED_BY_LOAD.contains(&table.as_str()) + && !READ_BY_A_DEDICATED_API.contains(&table.as_str()) + && !LOAD_UNIMPLEMENTED_TABLES.contains(&table.as_str()) + && !INFRASTRUCTURE.contains(&table.as_str()) + && !FEATURE_GATED.contains(&table.as_str()) + && !NOT_REHYDRATED_WITHOUT_FEATURE.contains(&table.as_str()) + }) + .collect(); + assert!( + unaccounted.is_empty(), + "unaccounted tables: {unaccounted:?}. A new table must join one of \ + this test's lists. If `load()` does not read it and no other entry \ + point does, it belongs in LOAD_UNIMPLEMENTED_TABLES so its rows are \ + COUNTED — do not add it here to silence the test." + ); + } + + /// `LOAD_UNIMPLEMENTED_TABLES` is hand-maintained beside the logical + /// `LOAD_UNIMPLEMENTED` list, so a table renamed in a migration would + /// otherwise surface as a failing probe on a user's database. + #[test] + fn every_unimplemented_table_exists_in_the_migrated_schema() { + let mut conn = Connection::open_in_memory().expect("in-memory db"); + crate::sqlite::migrations::run(&mut conn).expect("migrate"); + for table in LOAD_UNIMPLEMENTED_TABLES { + let found: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |row| row.get(0), + ) + .expect("probe sqlite_master"); + assert_eq!(found, 1, "{table} is missing from the migrated schema"); + } + assert_eq!( + count_unimplemented_rows(&conn).expect("the probe must run against the real schema"), + 0, + "a freshly migrated database holds no unread rows" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/provider_accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/provider_accounts.rs new file mode 100644 index 00000000000..32c5d66acbb --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/provider_accounts.rs @@ -0,0 +1,230 @@ +//! Provider account and public-key pool reconstruction for SQLite load. + +use key_wallet::account::AccountType; +use platform_wallet::changeset::ProviderKeyExtendedPubKey; + +/// Why a provider key-material account could not be rebuilt into an +/// [`AccountCollection`](key_wallet::account::account_collection::AccountCollection). +#[derive(Debug, thiserror::Error)] +pub(super) enum ProviderAccountRebuildError { + /// The curve-specific account constructor rejected the key. + #[error("provider key account is invalid")] + Invalid(#[from] key_wallet::error::Error), + /// The collection refused the account — its `account_type` does not match + /// the curve (e.g. a BLS key offered as `ProviderPlatformKeys`). + #[error("account collection rejected the provider key account: {0}")] + Rejected(&'static str), +} + +/// Rebuild a watch-only provider account in its curve-specific collection slot. +pub(super) fn rebuild_provider_key_account( + accounts: &mut key_wallet::account::account_collection::AccountCollection, + wallet_id: [u8; 32], + network: key_wallet::Network, + account_type: AccountType, + extended_public_key: &ProviderKeyExtendedPubKey, +) -> Result<(), ProviderAccountRebuildError> { + match extended_public_key { + ProviderKeyExtendedPubKey::Bls(key) => { + let account = key_wallet::account::BLSAccount::new( + Some(wallet_id.to_vec()), + account_type, + key.clone(), + network, + )?; + accounts + .insert_bls_account(account) + .map_err(ProviderAccountRebuildError::Rejected) + } + ProviderKeyExtendedPubKey::EdDSA(key) => { + let account = key_wallet::account::EdDSAAccount::new( + Some(wallet_id.to_vec()), + account_type, + key.clone(), + network, + )?; + accounts + .insert_eddsa_account(account) + .map_err(ProviderAccountRebuildError::Rejected) + } + } +} + +/// Errors while inserting a pre-derived platform-node key into its managed pool. +#[derive(Debug, thiserror::Error)] +pub(super) enum PlatformNodePoolError { + /// The wallet has no managed provider-platform account. + #[error("wallet has no managed provider platform account")] + NoManagedAccount, + /// The provider-platform account path cannot be constructed for the network. + #[error("provider platform account derivation path is invalid")] + InvalidAccountPath { + #[source] + source: key_wallet::error::Error, + }, + /// The managed provider-platform account lacks its hardened-only pool. + #[error("provider platform account has no AbsentHardened pool")] + MissingHardenedPool, + /// The persisted or derived index cannot form a hardened child number. + #[error("platform-node index {index} cannot form a hardened child number")] + InvalidChildIndex { + index: u32, + #[source] + source: key_wallet::error::Error, + }, +} + +/// Insert one pre-derived platform-node key and restore its usage state. +pub(super) fn insert_platform_node_pool_entry( + wallet_info: &mut key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, + network: key_wallet::Network, + index: u32, + address: dashcore::Address, + script_pubkey: dashcore::ScriptBuf, + public_key: [u8; 32], + used: bool, +) -> Result<(), PlatformNodePoolError> { + use key_wallet::managed_account::address_pool::{AddressPoolType, AddressState, PublicKeyType}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::AddressInfo; + + let Some(account) = wallet_info.accounts.provider_platform_keys.as_mut() else { + return Err(PlatformNodePoolError::NoManagedAccount); + }; + let account_path = AccountType::ProviderPlatformKeys + .derivation_path(network) + .map_err(|source| PlatformNodePoolError::InvalidAccountPath { source })?; + let pool = account + .managed_account_type_mut() + .address_pools_mut() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .ok_or(PlatformNodePoolError::MissingHardenedPool)?; + let child = key_wallet::bip32::ChildNumber::from_hardened_idx(index) + .map_err(key_wallet::error::Error::Bip32) + .map_err(|source| PlatformNodePoolError::InvalidChildIndex { index, source })?; + let mut children: Vec = account_path.as_ref().to_vec(); + children.push(child); + let info = AddressInfo { + address, + script_pubkey, + public_key: Some(PublicKeyType::EdDSA(public_key.to_vec())), + index, + path: key_wallet::bip32::DerivationPath::from(children), + state: if used { + AddressState::Used + } else { + AddressState::Available + }, + tx_count: 0, + total_received: 0, + total_sent: 0, + balance: 0, + label: None, + metadata: Default::default(), + }; + + pool.address_index.insert(info.address.clone(), index); + pool.script_pubkey_index + .insert(info.script_pubkey.clone(), index); + pool.highest_generated = Some( + pool.highest_generated + .map_or(index, |highest| highest.max(index)), + ); + if used { + pool.used_indices.insert(index); + pool.highest_used = Some( + pool.highest_used + .map_or(index, |highest| highest.max(index)), + ); + } else { + pool.used_indices.remove(&index); + pool.highest_used = pool.used_indices.iter().max().copied(); + } + pool.addresses.insert(index, info); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use key_wallet::Network; + + fn provider_key_test_wallet() -> key_wallet::wallet::Wallet { + key_wallet::wallet::Wallet::from_seed_bytes( + [0x42; 64], + Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .expect("provider key test wallet") + } + + #[test] + fn rebuild_provider_key_account_restores_bls_and_eddsa() { + let wallet = provider_key_test_wallet(); + let bls_key = wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS provider account") + .bls_public_key + .clone(); + let eddsa_key = wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA provider account") + .ed25519_public_key + .clone(); + let mut accounts = key_wallet::account::account_collection::AccountCollection::new(); + let wallet_id = [0x24; 32]; + + rebuild_provider_key_account( + &mut accounts, + wallet_id, + Network::Testnet, + AccountType::ProviderOperatorKeys, + &ProviderKeyExtendedPubKey::Bls(bls_key), + ) + .expect("rebuild BLS provider account"); + rebuild_provider_key_account( + &mut accounts, + wallet_id, + Network::Testnet, + AccountType::ProviderPlatformKeys, + &ProviderKeyExtendedPubKey::EdDSA(eddsa_key), + ) + .expect("rebuild EdDSA provider account"); + + assert!(accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .is_some()); + assert!(accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .is_some()); + } + + #[test] + fn rebuild_provider_key_account_rejects_curve_account_type_mismatch() { + let wallet = provider_key_test_wallet(); + let bls_key = wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS provider account") + .bls_public_key + .clone(); + let mut accounts = key_wallet::account::account_collection::AccountCollection::new(); + + let error = rebuild_provider_key_account( + &mut accounts, + [0x24; 32], + Network::Testnet, + AccountType::ProviderPlatformKeys, + &ProviderKeyExtendedPubKey::Bls(bls_key), + ) + .expect_err("BLS key must not rebuild as a platform-node account"); + + assert!(matches!(error, ProviderAccountRebuildError::Rejected(_))); + assert!(accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .is_none()); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs new file mode 100644 index 00000000000..c0be88399ac --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -0,0 +1,3074 @@ +//! External-signable wallet reconstruction +//! +//! Load is seedless — each wallet is rebuilt watch-only from its manifest and +//! the manager consumes the carried snapshot directly, so no wrong-seed check +//! runs here; that gate lives in the resolver-backed signing entrypoints. + +use key_wallet::account::account_collection::AccountCollection; +use key_wallet::account::{Account, AccountType}; +use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::Network; + +use platform_wallet::changeset::{AccountRegistrationEntry, CoreChangeSet}; + +use crate::sqlite::provider_accounts::{ + insert_platform_node_pool_entry, rebuild_provider_key_account, PlatformNodePoolError, + ProviderAccountRebuildError, +}; + +use crate::sqlite::load_ctx::{LoadCtx, LoadSite, SiteCoords}; +use crate::sqlite::schema::accounts::{self, AccountManifest}; +use crate::sqlite::schema::core_pool::{self, OwningAccount}; +use crate::WalletStorageError; + +/// Build a [`Wallet`] that will be provided to the platform-wallet during rehydration. +pub(crate) fn build_wallet( + network: Network, + expected_wallet_id: [u8; 32], + manifest: &AccountManifest, +) -> Result { + if manifest.is_empty() { + return Err(WalletStorageError::MissingAccount { + wallet_id: expected_wallet_id, + }); + } + let mut accounts = AccountCollection::new(); + for entry in &manifest.ecdsa { + // `Account::from_xpub` is infallible in the pinned key-wallet rev; this + // map_err is a defensive guard for when that signature becomes fallible. + let account = Account::from_xpub( + Some(expected_wallet_id), + entry.account_type, + entry.account_xpub, + network, + ) + .map_err(|e| WalletStorageError::AccountRecordInvalid { e })?; + accounts + .insert(account) + .map_err(|_| WalletStorageError::AccountRegistrationEntryMismatch)?; + } + // Provider accounts use separate curve-specific slots in the collection. + for entry in &manifest.provider { + rebuild_provider_key_account( + &mut accounts, + expected_wallet_id, + network, + entry.account_type, + &entry.extended_public_key, + ) + .map_err(|e| match e { + ProviderAccountRebuildError::Invalid(e) => { + WalletStorageError::AccountRecordInvalid { e } + } + ProviderAccountRebuildError::Rejected(_) => { + WalletStorageError::ProviderKeyAccountEntryMismatch + } + })?; + } + Ok(Wallet::new_external_signable( + network, + expected_wallet_id, + accounts, + )) +} + +/// Restore pre-derived platform-node public keys from typed address-pool rows. +/// +/// The account's `AbsentHardened` EdDSA entries cannot be regenerated from a +/// watch-only xpub, so they round-trip verbatim through +/// [`core_pool::load_typed_pool_entries`]. +pub(crate) fn restore_provider_platform_node_pool( + wallet_info: &mut ManagedWalletInfo, + conn: &rusqlite::Connection, + wallet_id: &platform_wallet::wallet::platform_wallet::WalletId, + network: Network, + ctx: &LoadCtx, +) -> Result<(), WalletStorageError> { + if wallet_info.accounts.provider_platform_keys.is_none() { + return Ok(()); + } + + let entries = core_pool::load_typed_pool_entries( + conn, + wallet_id, + &AccountType::ProviderPlatformKeys, + AddressPoolType::AbsentHardened, + )?; + if entries.is_empty() { + return Ok(()); + } + + // TODO(#4188): `reserved_at` is persisted but deliberately not consumed here; + // restoring it requires widening `provider_accounts::insert_platform_node_pool_entry`. + for (index, script_bytes, public_key, used) in entries { + let PublicKeyType::EdDSA(public_key) = public_key else { + return Err(WalletStorageError::blob_decode( + "provider platform pool row does not carry an EdDSA public key", + )); + }; + let public_key = public_key.try_into().map_err(|_| { + WalletStorageError::blob_decode( + "provider platform pool row has the wrong EdDSA public-key length", + ) + })?; + let script_pubkey = dashcore::ScriptBuf::from_bytes(script_bytes); + // Same condition `core_pool`/`core_state` tolerate as + // `LoadSite::UndecodableAddressScript`, so it must be tolerable here + // too — otherwise the crate tolerates an undecodable script in two + // readers and aborts on it in a third. Doubly worth it here: this + // error `?`-propagates out of `load()`'s per-wallet loop, so one + // damaged wallet would abort the load of EVERY wallet in the file, and + // the data at stake is a re-derivable cache of pre-derived + // platform-node public keys — no funds, no identity, no address-reuse + // guard. If anything in this crate should degrade rather than fail, + // it is this. + let address = match dashcore::Address::from_script(&script_pubkey, network) { + Ok(address) => address, + Err(e) => { + ctx.tolerate_at( + LoadSite::UndecodableAddressScript, + SiteCoords { + wallet_id: Some(*wallet_id), + account_type: &AccountType::ProviderPlatformKeys, + affected: 1, + detail: Some(&AddressPoolType::AbsentHardened), + }, + WalletStorageError::from(e), + )?; + continue; + } + }; + insert_platform_node_pool_entry( + wallet_info, + network, + index, + address, + script_pubkey, + public_key, + used, + ) + .map_err(|error| match error { + PlatformNodePoolError::NoManagedAccount => { + WalletStorageError::blob_decode("provider platform account is not managed") + } + PlatformNodePoolError::InvalidAccountPath { source } => { + WalletStorageError::AccountRecordInvalid { e: source } + } + PlatformNodePoolError::MissingHardenedPool => WalletStorageError::blob_decode( + "provider platform account has no AbsentHardened pool", + ), + PlatformNodePoolError::InvalidChildIndex { source, .. } => { + WalletStorageError::AccountRecordInvalid { e: source } + } + })?; + } + Ok(()) +} + +/// Apply the keyless persisted core-state projection onto a +/// freshly-minted `ManagedWalletInfo` skeleton. +/// +/// # Parameters +/// +/// - `wallet_info`: the skeleton to hydrate in place. +/// - `manifest`: keyless account manifest (one entry per registered +/// account). Each entry carries an `account_type` → `account_xpub` +/// mapping used by [`extend_pools_for_restored_addresses`] to derive +/// addresses for restored UTXOs. If an account's `account_type` is +/// absent from the manifest, deep-index derivation is skipped for that +/// account (no xpub → no derivation possible); already-derived in-window +/// addresses are still marked used. +/// - `core`: the persisted core-state changeset to apply. +/// - `utxo_accounts`: per-outpoint owning-account side channel from +/// [`load_state`](crate::sqlite::schema::core_state::load_state) — the +/// `CoreChangeSet` cannot carry it. Each restored unspent UTXO is routed +/// to the funds account whose identity matches its entry; a UTXO absent +/// from the map (its script matched no pool row) falls back to the first +/// funds account and re-warms on the next sync. +/// - `used_pool_addresses`: addresses the persisted pool snapshot marked +/// used, each mapped to its owning account (`None` when the script matched +/// no pool row). Each is routed to its owning funds account — via the same +/// identity match as `utxo_accounts` — and marked used there, in union with +/// the still-unspent UTXO addresses, so a previously-used address whose +/// funds were since spent is never re-handed-out as a fresh receive address +/// from its own account (address-reuse guard). An owner absent from this +/// wallet's funds accounts, or a `None` owner, falls back to the first +/// account. Empty = no pool used-state carried. +/// +/// # Reconstructed (safety-critical-correct) +/// +/// - **Wallet balance** (`wallet_info.balance`, the no-silent-zero +/// guarantee): every persisted UTXO is restored and the per-account +/// and wallet totals are recomputed via `update_balance()`. A UTXO +/// carrying a block height is marked confirmed so it lands in the +/// `confirmed` bucket; the wallet total is exact regardless. +/// - **UTXO set**: every unspent persisted outpoint is restored into its +/// owning funds-bearing account (matched via `utxo_accounts` across any +/// topology — BIP44, BIP32, CoinJoin, DashPay), so per-account balance, +/// coin selection, and reservations are correct after restart. An +/// outpoint that resolves to no account falls back to the first. +/// - **Address-pool depth**: each pool is forward-derived to cover +/// restored UTXOs at deep derivation indices, then the gap window is +/// refilled beyond the deepest restored index so the per-address view +/// reconciles with the wallet total. +/// - **Address-pool used-state**: every `used_pool_addresses` entry is +/// re-marked used (in union with the unspent-UTXO addresses), so an +/// address whose funds were since spent is not re-handed-out as fresh. +/// - **InstantSend locks**: every `instant_locks_for_non_final_records` +/// entry is replayed through `mark_instant_send_utxos` after the UTXO +/// restore, so instant-locked funds come back instant-locked instead of +/// waiting for the next sync to re-learn them. +/// - **Sync watermarks**: `synced_height` / `last_processed_height`. +/// +/// # Reconstructed when the persister supplies it +/// +/// - **`last_applied_chain_lock`**: restored from `core` on both backends +/// when the supplied [`CoreChangeSet`](platform_wallet::changeset::CoreChangeSet) +/// carries it, so the asset-lock-resume CL-from-metadata fallback +/// (`proof.rs`) fires at launch instead of waiting for SPV. The FFI/iOS +/// persister round-trips the value Swift held; the SQLite persister +/// reads it from `core_sync_state.last_applied_chain_lock` (present +/// since V001) via a monotonic height-max merge on write and +/// `decode_chain_lock` under the load policy on read. It stays `None` +/// only when the column is NULL or, under +/// [`LoadPolicy::Recovery`](crate::LoadPolicy), when the blob failed to +/// decode and was tolerated as [`LoadSite::ChainLockBlob`]. +/// +/// # Deferred to the first post-load `sync` (safe re-warm) +/// +/// - **Deep-index address visibility**: each chain's pool scan stops +/// after [`MAX_REHYDRATION_DERIVATION_INDEX`] or after `gap_limit` +/// consecutive non-matching indices past the deepest resolved index. +/// The horizon only advances when an unspent UTXO anchors a match, so a +/// UTXO address can be left unresolved in two distinct cases: (1) it is +/// genuinely foreign (a different account's key routed here, or corrupt), +/// and (2) it is a *legitimately-owned but deep-and-sparse* address — +/// owned by this account, yet sitting past the first `gap_limit` window +/// with no nearer unspent UTXO to walk the horizon out to it. Both cases +/// are counted and logged via `tracing::warn!` and re-warm on the next +/// full sync. The wallet *total* stays exact (every UTXO is summed +/// regardless of pool visibility); only the per-address view is +/// incomplete until that sync. This is the accepted behavior of the +/// horizon-walk algorithm — see [`extend_pools_for_restored_addresses`]. +/// - **Per-UTXO `is_coinbase` / `is_trusted` flags**: not columns in +/// `core_utxos`; conservatively defaulted (non-coinbase, +/// confirmed-by-height) and refreshed on the next scan. +/// Coinbase-maturity nuance re-warms on sync. `is_instantlocked` is NOT +/// among them: it is rebuilt from `core_instant_locks` above, for every +/// UTXO a replayed lock covers. +/// - **Transaction-record history**: rebuilt by the next scan; not a +/// balance input. +/// +/// # Errors +/// +/// [`WalletStorageError::MissingAccount`] if there are persisted UTXOs to +/// restore but the reconstructed account collection has **no** +/// funds-bearing account to hold them. Fail-closed rather than +/// reconstructing a silent zero balance (the no-silent-zero mandate). An +/// empty UTXO set is always `Ok`. +/// +/// This never touches key material. +pub fn apply_persisted_core_state( + wallet_info: &mut ManagedWalletInfo, + manifest: &[AccountRegistrationEntry], + core: &CoreChangeSet, + utxo_accounts: &std::collections::HashMap, + used_pool_addresses: &std::collections::HashMap>, + ctx: &LoadCtx, +) -> Result<(), WalletStorageError> { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + // Captured before the mutable account borrow below so it can flow into + // pool-extension diagnostics without re-borrowing `wallet_info`. + let wallet_id = wallet_info.wallet_id; + + // Sync watermarks first so `update_balance`'s maturity check sees + // the restored tip. + if let Some(h) = core.last_processed_height { + wallet_info.metadata.last_processed_height = + wallet_info.metadata.last_processed_height.max(h); + } + if let Some(h) = core.synced_height { + wallet_info.metadata.synced_height = wallet_info.metadata.synced_height.max(h); + } + + // Restore the highest applied chainlock when the persister carries it + // (FFI path) so the asset-lock proof CL-from-metadata fallback fires at launch. + if let Some(cl) = &core.last_applied_chain_lock { + wallet_info.metadata.last_applied_chain_lock = Some(cl.clone()); + } + + // INTENTIONAL(tx-record-rehydration-gap): `core` also carries transaction + // records, but they cannot be replayed here — injecting one needs the raw + // `dashcore::Transaction`, and this crate persists only the abstracted + // `TransactionRecord` blob. History re-warms on the next scan and is not a + // balance input. + + // Restore the UTXO set, routing each unspent outpoint to its true owning + // funds account via `utxo_accounts` (matched on the same account identity + // the writer keyed the pool row on). Two miss cases share the first-account + // best-effort fallback but differ in signal: + // (a) no side-channel entry — the UTXO's script matched no pool row; an + // expected fallback (deep/sparse or non-funds address) that re-warms + // on the next sync, no log. + // (b) a side-channel entry whose owner is not among this wallet's funds + // accounts — a pool row references an unregistered account (store + // drift). Counted and surfaced via `tracing::warn!` after the loop so + // the misattribution is never silent. + // A wallet with unspent UTXOs but no funds account at all fails closed + // rather than silently reconstructing a zero balance. + let spent_outpoints: std::collections::HashSet = + core.spent_utxos.iter().map(|u| u.outpoint).collect(); + let unspent: Vec<&key_wallet::Utxo> = core + .new_utxos + .iter() + .filter(|u| !spent_outpoints.contains(&u.outpoint)) + .collect(); + + let mut funding = wallet_info.accounts.all_funding_accounts_mut(); + if !unspent.is_empty() && funding.is_empty() { + return Err(WalletStorageError::MissingAccount { wallet_id }); + } + if !funding.is_empty() { + let account_keys: Vec = + funding.iter().map(|a| owning_account_of(a)).collect(); + + // Per-account addresses to derive-and-mark-used: each account gets only + // its own restored UTXO addresses, so `extend_pools_for_restored_addresses` + // never scans another account's keys as "unresolved". + let mut per_account_addrs: Vec> = vec![Vec::new(); funding.len()]; + + // Owners that resolve to no funds account (case (b) above), plus used + // addresses with an owner absent from this wallet — collected across + // both routing loops and warned once after them, never per iteration. + let mut orphaned_owners: Vec = Vec::new(); + for utxo in &unspent { + let target = route_to_funds_account( + &account_keys, + utxo_accounts.get(&utxo.outpoint), + &mut orphaned_owners, + ); + funding[target].utxos.insert(utxo.outpoint, (*utxo).clone()); + per_account_addrs[target].push(utxo.address.clone()); + } + + // The persisted pool used-state restores addresses whose funds were + // since spent — without it a previously-used address comes back marked + // unused and could be handed out again as a fresh receive address + // (address-reuse privacy leak). Each is routed to its owning funds + // account (same identity match as the UTXOs), so a used address on a + // non-first account is marked used on ITS OWN pool. A `None` owner, or + // one absent from this wallet, falls back to the first account. An + // empty map marks only the unspent-UTXO addresses. + for (addr, owner) in used_pool_addresses { + let target = + route_to_funds_account(&account_keys, owner.as_ref(), &mut orphaned_owners); + per_account_addrs[target].push(addr.clone()); + } + + // Degraded in BOTH policies, never fatal. An owner absent from the + // funds accounts is not necessarily corruption: provider accounts are + // first-class in the schema yet sit on a non-secp256k1 curve, so they + // are not `ManagedCoreFundsAccount` and a used provider-owned address + // has no funds account to route to. Telling that apart needs an + // upstream `key-wallet` enumerator over every account kind; until then, + // failing here would brick a masternode-operator wallet. + if !orphaned_owners.is_empty() { + ctx.note_degraded( + LoadSite::OrphanedUtxoOwner, + SiteCoords { + wallet_id: Some(wallet_id), + account_type: &orphaned_owners, + affected: orphaned_owners.len(), + detail: None, + }, + "restored UTXOs or used addresses were routed to the first funds account \ + because their own owning accounts are not funds accounts of this wallet; \ + the per-account view re-warms on the next sync", + ); + } + + // Eager derivation covers only `0..gap_limit`; extend each chain to + // cover restored / used addresses at deeper indices. + for i in 0..funding.len() { + if !per_account_addrs[i].is_empty() { + extend_pools_for_restored_addresses( + funding[i], + manifest, + &per_account_addrs[i], + wallet_id, + ctx, + )?; + } + } + } + + // Replay persisted InstantSend locks AFTER the UTXO restore: this marks the + // UTXOs it finds, so running it earlier would record the txid and mark + // nothing. Without it, instant-locked funds come back as merely confirmed + // and stay that way until the next sync re-learns the lock. + for (txid, lock) in &core.instant_locks_for_non_final_records { + wallet_info.mark_instant_send_utxos(txid, lock); + } + + // Recompute per-account + wallet balance from the restored set. + // After this, a non-zero persisted balance is non-zero here — a + // silent zero would be a hard FAIL of the rehydration contract. + wallet_info.update_balance(); + Ok(()) +} + +/// Resolve an owning account to its position among `account_keys`, or fall +/// back to the first funds account. A `None` owner (no attribution available) +/// falls back silently; an owner not present in `account_keys` (store drift) +/// falls back too but is recorded in `orphaned_owners` for a single post-loop +/// `tracing::warn!`. Shared by the UTXO and used-address routing loops so both +/// bucket funds and used-state by the exact same identity match. +fn route_to_funds_account( + account_keys: &[OwningAccount], + owner: Option<&OwningAccount>, + orphaned_owners: &mut Vec, +) -> usize { + // INTENTIONAL(funds-account-fallback): an unattributable owner routes to + // account 0 rather than failing. Accepted risk: until the next sync warms + // the per-account view, such a UTXO or used address is attributed to the + // first funds account instead of its own. Failing closed here would brick + // wallets whose owners legitimately have no funds account (provider + // accounts sit on a non-secp256k1 curve), which is the worse outcome. + match owner { + None => 0, + Some(owner) => account_keys + .iter() + .position(|k| k == owner) + .unwrap_or_else(|| { + orphaned_owners.push(format!("{}[{}]", owner.account_type, owner.account_index)); + 0 + }), + } +} + +/// Owning-account identity of a funds account, keyed on the same +/// discriminators the UTXO side channel resolves from `core_address_pool`: +/// the `account_type` label, numeric index, and DashPay identity pair. Enough +/// to pick one account among funding accounts that share a numeric index +/// (Standard BIP44/BIP32 and CoinJoin can all sit at index 0; DashPay accounts +/// all carry index 0 and differ only by the identity pair). +fn owning_account_of( + account: &key_wallet::managed_account::ManagedCoreFundsAccount, +) -> OwningAccount { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let at = account.managed_account_type().to_account_type(); + let (user_identity_id, friend_identity_id) = accounts::account_dashpay_ids(&at); + OwningAccount { + account_type: accounts::account_type_db_label(&at).to_string(), + account_index: accounts::account_index(&at), + user_identity_id, + friend_identity_id, + } +} + +/// Upper bound on forward derivation while resolving a restored UTXO +/// address to its derivation index. Addresses that don't resolve within +/// this many indices (e.g. they belong to a different funds account whose +/// UTXOs were routed here, or are corrupt) are left for the next full +/// rescan to re-warm — generous enough to cover any realistic per-account +/// derivation depth. The common (single funds account) path terminates at +/// the true high-water mark well before this and never reaches the cap. +const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000; + +/// Soft threshold past which a single chain's discovery scan is treated as +/// abnormally deep and worth a `tracing::warn!`. Real funds chains anchor +/// well below this; reaching it means either a corrupt / foreign-heavy UTXO +/// set walking the horizon out, or an approach toward the hard +/// [`MAX_REHYDRATION_DERIVATION_INDEX`] ceiling — both worth surfacing. +const REHYDRATION_DEEP_SCAN_WARN_INDEX: u32 = 1_000; + +/// Upper bound on the addresses one gap-limit refill may generate during +/// load. A generated address costs roughly 500 bytes — an `AddressInfo` +/// plus its three pool index entries, two of which clone the +/// `Address` / `ScriptBuf` — so this holds a single refill near 100 MB. +/// Legitimate refills span one gap window (tens of addresses); reaching +/// this cap needs `highest_used` far past `highest_generated`, which +/// `mark_used` — the only writer today — cannot produce. Defense in depth +/// against a future upstream invariant break, not a currently reachable path. +const MAX_REHYDRATION_GAP_REFILL: u32 = 250_000; + +/// Highest BIP-32 non-hardened child index. A derivation target at or past +/// `2^31` names a hardened child, which no address pool can derive from a +/// public xpub — such a target is corruption, not a legitimately deep wallet. +const MAX_NORMAL_CHILD_INDEX: u32 = (1u32 << 31) - 1; + +/// Extend `account`'s address pools so every resolved address (a +/// still-unspent UTXO address or a persisted pool used-address) is derived +/// at its exact `(chain, index)` slot and marked used, then refill the gap +/// window beyond — following the sync path's `mark_used` → +/// `maintain_gap_limit` sequence. Each chain is scanned independently, +/// stopping once no unresolved address matches within a `gap_limit`-sized +/// window past the deepest resolved index; [`MAX_REHYDRATION_DERIVATION_INDEX`] +/// is the hard ceiling. Addresses that don't resolve from this account's +/// xpub — foreign keys, multi-account mismatch, or legitimately-owned but +/// deep-and-sparse slots with no nearer resolved address to anchor the horizon — +/// are counted and logged via `tracing::warn!`; they re-warm on the next +/// full sync. Every resolved address the pools *do* hold (in-window or +/// deep-resolved) is marked used so a funded or previously-used address is +/// never handed out as a fresh receive address. +/// +/// Tested with Standard BIP44 topology (External + Internal pools) and +/// CoinJoin topology (single External pool). The per-chain probe loop has no +/// topology-specific branches, so the non-hardened single-pool type +/// (`Absent`) follows the same code path with a different relative derivation +/// path. `AbsentHardened` pools cannot be derived from a public xpub at all — +/// hardened child derivation needs the private key — so under watch-only +/// rehydration their addresses never resolve and always defer to the next +/// sync (shared code path, but the outcome is "unresolved"). +/// +/// # Errors +/// +/// [`WalletStorageError::RehydrationPoolMismatch`] if the discovery probes +/// don't mirror the real pools 1:1 (a structural invariant break, not +/// user-reachable). Fail-closed rather than apply a probe depth to the wrong +/// pool by position. +/// +/// Never touches key material — the xpub is the keyless account public key. +fn extend_pools_for_restored_addresses( + account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, + manifest: &[AccountRegistrationEntry], + restored_addresses: &[key_wallet::Address], + wallet_id: [u8; 32], + ctx: &LoadCtx, +) -> Result<(), WalletStorageError> { + use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use std::collections::HashSet; + + let account_type = account.managed_account_type().to_account_type(); + + // The funds account carries no key material; recover its watch-only xpub + // from the keyless manifest by account type. Without it we cannot derive + // deeper, but can still mark already-derived (in-window) addresses used. + let key_source = manifest + .iter() + .find(|e| e.account_type == account_type) + .map(|e| KeySource::Public(e.account_xpub)); + + // Probe pools mirror each real pool's chain 1:1 so the index search + // derives into throwaway state (real pools keep their own exact depth) + // and the resolved depth can be applied back by position. Re-deriving + // each probe from index 0 is an accepted, bounded one-time-load cost + // (per chain capped at MAX_REHYDRATION_DERIVATION_INDEX); rehydration + // runs once per wallet at startup, never on a hot path. + let mut probes: Vec<(AddressPool, Option)> = account + .managed_account_type() + .address_pools() + .iter() + .map(|p| { + ( + AddressPool::new_without_generation( + p.base_path.clone(), + p.pool_type, + p.gap_limit, + p.network, + ), + None, + ) + }) + .collect(); + + // Deep-index discovery (requires the xpub): resolve restored addresses the + // eager derivation didn't already cover, recording the matching index per + // chain. Each chain advances independently and stops once no unresolved + // address resolves within gap_limit indices past its deepest match + // (preventing a full scan when the UTXO set carries foreign addresses); + // MAX_REHYDRATION_DERIVATION_INDEX is the hard ceiling regardless. + if let Some(key_source) = key_source.as_ref() { + let mut unresolved: HashSet = { + let pools = account.managed_account_type().address_pools(); + restored_addresses + .iter() + .filter(|addr| !pools.iter().any(|p| p.contains_address(addr))) + .cloned() + .collect() + }; + + for (probe, deepest_resolved) in probes.iter_mut() { + if unresolved.is_empty() { + break; + } + let chain_gap = probe.gap_limit; + let mut index: u32 = 0; + + loop { + // Horizon: gap_limit past the deepest match, or the initial + // gap_limit window when nothing has resolved yet. + let horizon = deepest_resolved + .map(|d| d.saturating_add(chain_gap)) + .unwrap_or(chain_gap); + + if index > horizon || index > MAX_REHYDRATION_DERIVATION_INDEX { + break; + } + + if let Some(addr) = ensure_derived(probe, key_source, index) { + // Indices are visited in ascending order, so the last match + // is the deepest — record it directly (no per-chain set). + if unresolved.remove(&addr) { + *deepest_resolved = Some(index); + } + } + + if unresolved.is_empty() { + break; + } + + index = index.saturating_add(1); + } + + // Surface an abnormally deep scan once per chain (outside the loop + // — never log inside the per-index walk). + if index > REHYDRATION_DEEP_SCAN_WARN_INDEX { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + pool_type = ?probe.pool_type, + deepest_resolved = ?deepest_resolved, + scanned_to = index.saturating_sub(1), + "rehydration: chain discovery scanned abnormally deep — \ + likely a foreign-heavy or sparse UTXO set" + ); + } + } + + // Still-unresolved addresses are either foreign (a different account's + // key routed here, or corrupt) or legitimately-owned but deep-and-sparse + // (past the first gap window with no nearer unspent UTXO to anchor the + // horizon). Either way they re-warm on the next full sync; the wallet + // total is exact regardless. + // Degraded in BOTH policies, never fatal: the two explanations above + // are indistinguishable from the persisted rows alone, and no + // root-cause fix exists short of an unbounded scan. The wallet total + // is exact either way. + if !unresolved.is_empty() { + ctx.note_degraded( + LoadSite::UnresolvedUtxoAddress, + SiteCoords { + wallet_id: Some(wallet_id), + account_type: &account_type, + affected: unresolved.len(), + detail: None, + }, + "restored addresses did not resolve against this account's xpub; they re-warm \ + on the next full sync and the balance total is exact", + ); + } + } + + // No explicit aggregate-derivation cap is needed: a funds account exposes + // a fixed, small number of chains (Standard = 2, others = 1), each already + // capped at MAX_REHYDRATION_DERIVATION_INDEX, so total derivation is bounded + // by chains × MAX with no unbounded growth — an aggregate cap would either + // equal that natural bound (no-op) or clip a legitimate deep multi-chain + // wallet. The per-chain ceiling plus the deep-scan warn above are the + // proportionate guard against a corrupt/foreign-heavy UTXO set. + + // Apply discovered depths and mark restored addresses used. `probes` is + // built directly from `address_pools()`, so it mirrors `address_pools_mut()` + // 1:1 and in chain order; verify that invariant before zipping by position. + let mut pools = account.managed_account_type_mut().address_pools_mut(); + if pools.len() != probes.len() { + return Err(WalletStorageError::RehydrationPoolMismatch { + expected: probes.len(), + found: pools.len(), + }); + } + for (position, (pool, (probe, deepest_resolved))) in + pools.iter_mut().zip(probes.iter()).enumerate() + { + // `iter_mut()` over `Vec<&mut AddressPool>` yields `&mut &mut _`; + // reborrow once so the pool flows into `ensure_derived` cleanly. + let pool: &mut AddressPool = pool; + + // Runtime fail-closed guard (a release build compiles out a + // `debug_assert!`): applying a probe's depth to a pool of a different + // chain would misattribute derivation to the wrong pool by position. + if pool.pool_type != probe.pool_type { + return Err(WalletStorageError::RehydrationPoolTypeMismatch { + position, + expected: probe.pool_type, + found: pool.pool_type, + }); + } + + // Derive up to the deepest discovered index so its address exists in + // the real pool before we mark it used. + // NOTE(recovery-mode): defensive site with no reachable seed from + // the storage layer — the probe resolved this index from the same + // xpub, so the real pool derives it too. Kept fail-closed because a + // deferred address is an address that can be re-issued as fresh. + if let Some(deepest) = *deepest_resolved { + if let Some(key_source) = key_source.as_ref() { + if ensure_derived(pool, key_source, deepest).is_none() { + ctx.tolerate_at( + LoadSite::RehydrationEnsureDerived, + SiteCoords { + wallet_id: Some(wallet_id), + account_type: &account_type, + affected: 1, + detail: Some(&pool.pool_type), + }, + WalletStorageError::RehydrationEnsureDerivedFailed { index: deepest }, + )?; + } + } + } + + // Mark every restored address this pool now holds as used — covers both + // deep-resolved addresses (just derived) and in-window addresses the + // discovery scan never visits. Without this an already-derived but + // funded address keeps `used = false` and could be handed out as a fresh + // receive address. `mark_used` is a no-op for addresses not in this + // pool, so an underived (foreign / sparse) index is never marked. + // + // Mark ↔ refill runs to a FIXPOINT: marking raises `highest_used`, + // whose gap refill can derive a deeper previously-used address that + // the discovery walk missed (e.g. used idx 45 with in-window used + // idx 20 and gap 30 — the walk's horizon stops at 30, but the refill + // reaches 50 and derives idx 45). A single mark-then-refill pass + // would leave that address in the pool with `used = false`, handing + // a previously-used address back out as fresh. Terminates: each + // round marks at least one new address from the finite restored set + // (`mark_used` returns `true` only on an unused→used flip). + loop { + let mut marked_any = false; + for addr in restored_addresses { + if pool.mark_used(addr) { + marked_any = true; + } + } + if !marked_any { + break; + } + // Refill the gap window past the deepest used index (needs the + // xpub); without one no deeper address can be derived, so a + // single mark pass is all that's possible. + let Some(key_source) = key_source.as_ref() else { + break; + }; + // Bound the refill before it runs, so nothing is allocated on + // the way out. Validity first: an unrepresentable target has no + // meaningful cost to weigh against the cap, and upstream panics + // computing it (debug) or wraps it into a bogus one (release). + let refill = match ImpliedRefill::of( + pool.highest_used, + pool.highest_generated, + pool.gap_limit, + ) { + Ok(refill) => refill, + Err(error) => { + ctx.tolerate_at( + LoadSite::RehydrationMaintainGapLimit, + SiteCoords { + wallet_id: Some(wallet_id), + account_type: &account_type, + affected: 1, + detail: Some(&pool.pool_type), + }, + error, + )?; + break; + } + }; + if refill.implied > MAX_REHYDRATION_GAP_REFILL { + ctx.tolerate_at( + LoadSite::RehydrationGapLimit, + SiteCoords { + wallet_id: Some(wallet_id), + account_type: &account_type, + affected: 1, + detail: Some(&pool.pool_type), + }, + WalletStorageError::RehydrationGapLimitRefillTooLarge { + refill_target: refill.target, + already_generated: refill.already_generated, + implied: refill.implied, + cap: MAX_REHYDRATION_GAP_REFILL, + }, + )?; + break; + } + + // Defense in depth against an upstream derivation failure. The + // pre-flight above rejects every target this crate can compute as + // out of range, so no fixture currently reaches this branch. + // + // Kept fail-closed like `ensure_derived` above: a short window + // means a previously-used address can be re-issued as fresh. + if let Err(e) = pool.maintain_gap_limit(key_source) { + ctx.tolerate_at( + LoadSite::RehydrationMaintainGapLimit, + SiteCoords { + wallet_id: Some(wallet_id), + account_type: &account_type, + affected: 1, + detail: Some(&pool.pool_type), + }, + WalletStorageError::RehydrationGapLimitFailed { source: e }, + )?; + break; + } + } + } + Ok(()) +} + +/// What a `maintain_gap_limit` call on a pool would cost, costed before it +/// runs. +/// +/// Mirrors the target upstream computes internally (key-wallet rev +/// 393b612) — a private formula, so an upstream change would silently +/// mis-estimate here until `AddressPool` exposes a read-only +/// `refill_target()` (upstream work, out of scope). Upstream computes that +/// target with raw arithmetic — a panic with debug assertions on, a wrapped +/// target without — so the same formula is applied checked here; the cost +/// derived from it still saturates, because over-estimating fails closed. +#[derive(Debug)] +struct ImpliedRefill { + /// Highest derivation index the refill would have to reach. + target: u32, + /// Addresses the pool already holds. + already_generated: u32, + /// Addresses the refill would derive. + implied: u32, +} + +impl ImpliedRefill { + /// # Errors + /// + /// [`WalletStorageError::RehydrationGapLimitTargetOutOfRange`] when the + /// target over- or underflows `u32`, or names a hardened child index. + fn of( + highest_used: Option, + highest_generated: Option, + gap_limit: u32, + ) -> Result { + let target = match highest_used { + None => gap_limit.checked_sub(1), + Some(highest) => highest.checked_add(gap_limit), + } + .filter(|target| *target <= MAX_NORMAL_CHILD_INDEX) + .ok_or(WalletStorageError::RehydrationGapLimitTargetOutOfRange { + highest_used, + gap_limit, + })?; + // Both fields are INDICES: a pool with nothing generated holds no + // addresses, and one generated through index 0 holds one. Counting + // `None` as index 0 would under-count the work by one and report a + // generated address that does not exist. + let already_generated = highest_generated.map_or(0, |highest| highest.saturating_add(1)); + Ok(Self { + target, + already_generated, + implied: target.saturating_add(1).saturating_sub(already_generated), + }) + } +} + +/// Ensure `pool` has derived through `index` (generating only the missing +/// tail), and return that index's address. `None` only on a derivation +/// error. +fn ensure_derived( + pool: &mut key_wallet::managed_account::address_pool::AddressPool, + key_source: &key_wallet::managed_account::address_pool::KeySource, + index: u32, +) -> Option { + let needs_more = match pool.highest_generated { + Some(highest) => highest < index, + None => true, + }; + if needs_more { + let start = pool.highest_generated.map(|h| h + 1).unwrap_or(0); + pool.generate_addresses(index - start + 1, key_source, true) + .ok()?; + } + pool.address_at_index(index) +} + +#[cfg(test)] +mod tests { + use super::*; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + fn manifest_for(w: &Wallet) -> Vec { + w.accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect() + } + + #[test] + fn watch_only_rebuild_round_trips_manifest_and_id() { + let seed = [3u8; 64]; + let w = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let id = w.compute_wallet_id(); + let ecdsa = manifest_for(&w); + let manifest = AccountManifest { + ecdsa: ecdsa.clone(), + provider: Vec::new(), + }; + + let restored = build_wallet(Network::Testnet, id, &manifest).unwrap(); + assert_eq!(restored.wallet_id, id); + assert_eq!(restored.compute_wallet_id(), id); + let restored_types: Vec<_> = restored + .accounts + .all_accounts() + .into_iter() + .map(|a| a.account_type) + .collect(); + let manifest_types: Vec<_> = ecdsa.iter().map(|e| e.account_type).collect(); + assert_eq!(restored_types.len(), manifest_types.len()); + for t in &manifest_types { + assert!(restored_types.contains(t)); + } + } + + #[test] + fn empty_manifest_is_missing_manifest() { + let err = build_wallet(Network::Testnet, [0u8; 32], &AccountManifest::default()) + .expect_err("empty manifest must be MissingManifest"); + assert!(matches!(err, WalletStorageError::MissingAccount { .. })); + } + + /// Regression: after restart-in-place the watch-only pools eagerly + /// cover only `0..gap_limit`, but persisted UTXOs can sit at deeper + /// derivation indices. Rehydration must extend each chain's pool to its + /// deepest restored index so the per-address view reconciles with the + /// wallet total instead of undercounting. + /// + /// Index layout (gap_limit = 30): + /// - external idx 3: within eager window (not in `unresolved`), balance included + /// - external idx 30: first index past eager window; anchors the initial scan + /// window and extends it to idx 60 + /// - external idx 50: within extended window (50 < 60), resolved + /// - internal idx 30: within initial scan window, resolved + /// + /// Standard BIP44 topology (External + Internal pools) is exercised. + /// Asserts that maintain_gap_limit fills beyond the deepest resolved. + #[test] + fn rehydration_extends_pools_to_cover_deep_index_utxos() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let seed = [7u8; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + + // Mint the watch-only skeleton (pools cover only the eager gap + // window) and resolve the first funds account's keyless xpub. + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // Derive addresses on each chain from the same account xpub the + // pools use; `base_path` is record-keeping only and does not affect + // the derived address, so DerivationPath::master() is fine here. + let derive = |pool_type, index: u32| -> Address { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + pool_type, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(index + 1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(index).unwrap() + }; + + // idx 3: within eager window (0..=29) — covered by init, NOT in + // unresolved. Contributes to balance but needs no pool extension. + let shallow_recv = derive(AddressPoolType::External, 3); + // idx 30: first past eager window; falls in initial scan window + // (horizon = gap_limit = 30 on a chain with no prior matches). + // Anchors the external probe and extends horizon to 60. + let mid_recv = derive(AddressPoolType::External, 30); + // idx 50: within the extended window (50 < 30+30=60), resolved. + let deep_recv = derive(AddressPoolType::External, 50); + // idx 30: within the internal chain's initial scan window (<=30). + let deep_change = derive(AddressPoolType::Internal, 30); + + let utxo = |addr: Address, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from([n; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + let new_utxos = vec![ + utxo(shallow_recv, 1_000, 1), + utxo(mid_recv.clone(), 10_000, 2), + utxo(deep_recv.clone(), 20_000, 3), + utxo(deep_change.clone(), 300_000, 4), + ]; + let expected_total: u64 = new_utxos.iter().map(|u| u.value()).sum(); + let core = platform_wallet::changeset::CoreChangeSet { + new_utxos, + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + &LoadCtx::strict(), + ) + .unwrap(); + + // The wallet total is exact regardless (a sum over the UTXO set). + assert_eq!(wallet_info.balance.total(), expected_total); + + // The per-address view joins pool addresses to UTXOs; every + // resolved UTXO address must now be derived into a pool. + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pool_addresses: HashSet
= funds + .managed_account_type() + .address_pools() + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, expected_total, + "all UTXO addresses (including deep-index) must be derived into their pools" + ); + + // Each deep address resolves to its exact derivation slot. + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + let internal = pools.iter().find(|p| p.is_internal()).unwrap(); + assert_eq!(external.address_at_index(30).as_ref(), Some(&mid_recv)); + assert_eq!(external.address_at_index(50).as_ref(), Some(&deep_recv)); + assert_eq!(internal.address_at_index(30).as_ref(), Some(&deep_change)); + + // maintain_gap_limit must refill BEYOND the deepest restored + // index so the gap window is actually exercised, not just the restore. + // Deepest external resolved = idx 50; gap window must reach >= 50+30=80. + let expected_min_gen = 50 + DEFAULT_EXTERNAL_GAP_LIMIT; + assert!( + external.highest_generated >= Some(expected_min_gen), + "maintain_gap_limit must extend external pool to >= {} (got {:?})", + expected_min_gen, + external.highest_generated, + ); + } + + /// Regression (dashpay/platform#3968): restored unspent UTXOs must land in + /// their TRUE owning funds account, not collapse onto the first. A `Default` + /// wallet carries Standard BIP44, BIP32, and CoinJoin accounts all at numeric + /// index 0, so routing by bare `account_index` is ambiguous — the + /// owning-account side channel disambiguates by account type. Asserts each + /// account holds only its own UTXO and its per-account balance is exact. + #[test] + fn rehydration_routes_utxos_to_their_owning_account() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashMap; + + let seed = [11u8; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + // The two funds accounts that share numeric index 0 but differ by type. + let bip44_type = wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .unwrap() + .managed_account_type() + .to_account_type(); + let coinjoin_type = wallet_info + .accounts + .coinjoin_accounts + .get(&0) + .unwrap() + .managed_account_type() + .to_account_type(); + + // Derive external index-0 address from a given account xpub; `base_path` + // is record-keeping only and does not affect the derived address. + let derive = |at: key_wallet::account::AccountType| -> Address { + let xpub = manifest + .iter() + .find(|e| e.account_type == at) + .map(|e| e.account_xpub) + .expect("account xpub in manifest"); + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(0).unwrap() + }; + + let utxo = |addr: Address, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from([n; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + let bip44_utxo = utxo(derive(bip44_type), 5_000, 1); + let coinjoin_utxo = utxo(derive(coinjoin_type), 7_000, 2); + let bip44_op = bip44_utxo.outpoint; + let coinjoin_op = coinjoin_utxo.outpoint; + + // Side channel attributing each outpoint to its true owning account — + // keyed exactly as production resolves it from `core_address_pool`. + let mut utxo_accounts: HashMap = HashMap::new(); + utxo_accounts.insert( + bip44_op, + owning_account_of( + wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .unwrap(), + ), + ); + utxo_accounts.insert( + coinjoin_op, + owning_account_of(wallet_info.accounts.coinjoin_accounts.get(&0).unwrap()), + ); + + let core = platform_wallet::changeset::CoreChangeSet { + new_utxos: vec![bip44_utxo, coinjoin_utxo], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &utxo_accounts, + &Default::default(), + &LoadCtx::strict(), + ) + .unwrap(); + + let bip44 = wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .unwrap(); + let coinjoin = wallet_info.accounts.coinjoin_accounts.get(&0).unwrap(); + + assert!( + bip44.utxos.contains_key(&bip44_op), + "BIP44 UTXO must route to the BIP44 account" + ); + assert!( + !bip44.utxos.contains_key(&coinjoin_op), + "CoinJoin UTXO must NOT collapse onto the first (BIP44) account" + ); + assert!( + coinjoin.utxos.contains_key(&coinjoin_op), + "CoinJoin UTXO must route to the CoinJoin account" + ); + assert!(!coinjoin.utxos.contains_key(&bip44_op)); + + assert_eq!( + bip44.balance.total(), + 5_000, + "per-account BIP44 balance must be exact" + ); + assert_eq!( + coinjoin.balance.total(), + 7_000, + "per-account CoinJoin balance must be exact, not zero" + ); + assert_eq!( + wallet_info.balance.total(), + 12_000, + "wallet total is the sum across accounts" + ); + } + + /// Regression (dashpay/platform#3968): a restored *used* address (its funds + /// since spent, so no unspent UTXO anchors it) owned by a non-first funds + /// account must be marked used on ITS OWN account's pool — not collapsed + /// onto the first account. On a `Default` wallet CoinJoin[0] is not first + /// (Standard BIP44[0] is), so a used CoinJoin address routed by owner must + /// land `used` in the CoinJoin pool and be absent from the BIP44 pool — + /// otherwise it stays "unused" on CoinJoin and could be re-issued as a + /// fresh receive address (the address-reuse privacy leak). + #[test] + fn rehydration_routes_used_address_to_owning_account() { + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Address; + use std::collections::HashMap; + + let wallet = Wallet::from_seed_bytes( + [12u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let coinjoin_type = wallet_info + .accounts + .coinjoin_accounts + .get(&0) + .unwrap() + .managed_account_type() + .to_account_type(); + + // CoinJoin external index-0 address (in the eager window, already + // derived in the CoinJoin pool) — a previously-used receive address. + let coinjoin_used: Address = { + let xpub = manifest + .iter() + .find(|e| e.account_type == coinjoin_type) + .map(|e| e.account_xpub) + .expect("coinjoin xpub in manifest"); + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(0).unwrap() + }; + + // Known owner: CoinJoin[0], exactly as the pool resolver attributes it. + let mut used: HashMap> = HashMap::new(); + used.insert( + coinjoin_used.clone(), + Some(owning_account_of( + wallet_info.accounts.coinjoin_accounts.get(&0).unwrap(), + )), + ); + + // No UTXOs — only the persisted pool used-state. + let core = platform_wallet::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &used, + &LoadCtx::strict(), + ) + .unwrap(); + + let coinjoin = wallet_info.accounts.coinjoin_accounts.get(&0).unwrap(); + let cj_external = coinjoin + .managed_account_type() + .address_pools() + .into_iter() + .find(|p| p.pool_type == AddressPoolType::External) + .expect("CoinJoin External pool"); + assert!( + cj_external + .address_info(&coinjoin_used) + .expect("used address must be present in the CoinJoin pool") + .is_used(), + "used CoinJoin address must be marked used on the CoinJoin pool, not account 0" + ); + + // It must NOT have been (mis)routed onto the first (BIP44) account. + let bip44 = wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .unwrap(); + for pool in bip44.managed_account_type().address_pools() { + assert!( + pool.address_info(&coinjoin_used).is_none(), + "the CoinJoin used address must not appear in any BIP44 pool" + ); + } + } + + /// A used address whose owning account is not one of this wallet's funds + /// accounts — what a masternode-operator wallet looks like, since provider + /// accounts sit on a non-secp256k1 curve and are not funds accounts at all. + /// Degraded in every policy, fatal in none (dashpay/platform#3968). + #[test] + fn rehydration_orphaned_used_address_owner_is_degraded_not_fatal() { + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Address; + use std::collections::HashMap; + + let wallet = Wallet::from_seed_bytes( + [13u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + // An owner this wallet has no funds account for. + let mut used: HashMap> = HashMap::new(); + used.insert( + first_external_address(&wallet_info, &manifest), + Some(OwningAccount { + account_type: "provider_platform".to_string(), + account_index: 0, + user_identity_id: [0u8; 32], + friend_identity_id: [0u8; 32], + }), + ); + + let core = platform_wallet::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + let ctx = LoadCtx::strict(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &used, + &ctx, + ) + .expect("an unroutable owner must never brick a strict load"); + + let degradation = ctx.degradation(); + assert!(degradation.degraded); + assert_eq!( + degradation.by_site.get(&LoadSite::OrphanedUtxoOwner), + Some(&1), + "the unroutable owner must be counted: {:?}", + degradation.by_site + ); + } + + /// Two unroutable owners are two incidents. Every existing test at this + /// site seeds exactly one address, which cannot tell "count the rows" + /// apart from "count the times the reader decided to tolerate them" — + /// and a rescue operator sizing the damage needs the former. + #[test] + fn rehydration_orphaned_used_address_owners_are_counted_per_address() { + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Address; + use std::collections::HashMap; + + let wallet = Wallet::from_seed_bytes( + [14u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let orphan_owner = OwningAccount { + account_type: "provider_platform".to_string(), + account_index: 0, + user_identity_id: [0u8; 32], + friend_identity_id: [0u8; 32], + }; + let mut used: HashMap> = HashMap::new(); + for index in 0..2 { + used.insert( + external_address_at(&wallet_info, &manifest, index), + Some(orphan_owner.clone()), + ); + } + + let core = platform_wallet::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + let ctx = LoadCtx::strict(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &used, + &ctx, + ) + .expect("unroutable owners must never brick a strict load"); + + assert_eq!( + ctx.degradation().by_site.get(&LoadSite::OrphanedUtxoOwner), + Some(&2), + "both unroutable addresses must be counted" + ); + } + + /// External index-0 address of the wallet's first funds account. + fn first_external_address( + wallet_info: &key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, + manifest: &[AccountRegistrationEntry], + ) -> key_wallet::Address { + external_address_at(wallet_info, manifest, 0) + } + + /// External address at `index` of the wallet's first funds account. + fn external_address_at( + wallet_info: &key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, + manifest: &[AccountRegistrationEntry], + index: u32, + ) -> key_wallet::Address { + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let account_type = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .expect("a funds account") + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == account_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + let mut pool = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + pool.generate_addresses(index + 1, &KeySource::Public(xpub), true) + .unwrap(); + pool.address_at_index(index).unwrap() + } + + /// A UTXO whose address is not derivable from this account's + /// xpub (foreign key, multi-account mismatch) must not cause a panic or + /// hang. The total balance is exact (the UTXO is in the set regardless), + /// but the foreign address is absent from the pool so per-address + /// visibility is reduced. `tracing::warn!` fires for the unresolved count. + #[test] + fn rehydration_unresolvable_address_is_deferred_not_panics() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let seed = [13u8; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // Normal UTXO at external index 3 (within eager window, pool-visible). + let normal_addr = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(4, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(3).unwrap() + }; + + // Foreign address: derive from a completely different wallet seed so + // it cannot be resolved from this wallet's xpub. + let foreign_addr = { + let fw = Wallet::from_seed_bytes( + [99u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let fw_info = ManagedWalletInfo::from_wallet(&fw, 1); + fw_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap() + .managed_account_type() + .address_pools() + .first() + .unwrap() + .address_at_index(0) + .unwrap() + }; + assert_ne!( + normal_addr, foreign_addr, + "test fixture: foreign address must differ from normal" + ); + + let utxo = |addr: Address, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from([n; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + + let normal_val = 100_000u64; + let foreign_val = 200_000u64; + let expected_total = normal_val + foreign_val; + + let core = platform_wallet::changeset::CoreChangeSet { + new_utxos: vec![ + utxo(normal_addr, normal_val, 1), + utxo(foreign_addr, foreign_val, 2), + ], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + // Must not panic. tracing::warn! fires for the unresolved count. + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + &LoadCtx::strict(), + ) + .unwrap(); + + // Total balance is exact — foreign UTXO is in the set regardless. + assert_eq!( + wallet_info.balance.total(), + expected_total, + "total must include foreign UTXO even though it is unresolved" + ); + + // Per-address visible: only the normal UTXO is in the pool. + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pool_addresses: HashSet
= funds + .managed_account_type() + .address_pools() + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, normal_val, + "only the non-foreign UTXO is pool-visible; foreign deferred to re-warm" + ); + assert!( + visible < expected_total, + "foreign UTXO is deferred — per-address visible < total" + ); + } + + /// CoinJoin topology (External pool, deep index). + /// Verifies that `extend_pools_for_restored_addresses` handles the + /// CoinJoin External pool at a deep derivation index (idx 30, just past + /// the eager window). CoinJoin accounts carry both an External and an + /// Internal pool (mirroring `Standard`); this test exercises the + /// External side only. + #[test] + fn rehydration_coinjoin_single_pool_deep_index() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Utxo; + use std::collections::BTreeSet; + + // CoinJoin-only wallet: no BIP44, one CoinJoin account at index 0. + let mut cj_set = BTreeSet::new(); + cj_set.insert(0u32); + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + cj_set, + BTreeSet::new(), + BTreeSet::new(), + None, + ); + let seed = [11u8; 64]; + let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, opts).unwrap(); + assert!( + !wallet.accounts.coinjoin_accounts.is_empty(), + "fixture must have a CoinJoin account" + ); + + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + // Extract pool metadata before the mutable borrow of wallet_info. + let (funds_type, pool_base_path, pool_type_val, pool_gap_limit) = { + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .expect("CoinJoin account must be the only funds account"); + let ft = funds.managed_account_type().to_account_type(); + let pools = funds.managed_account_type().address_pools(); + // CoinJoin carries both an External and an Internal pool; this + // test targets the External side specifically. + let p = pools + .iter() + .find(|p| p.pool_type == AddressPoolType::External) + .expect("CoinJoin topology: must have an External pool"); + (ft, p.base_path.clone(), p.pool_type, p.gap_limit) + }; + + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("CoinJoin xpub must be in manifest"); + + // Derive the CoinJoin address at index 30 (first past the eager + // window 0..=29) using the real pool's base_path and pool_type. + let mut probe = AddressPool::new_without_generation( + pool_base_path, + pool_type_val, + pool_gap_limit, + Network::Testnet, + ); + probe + .generate_addresses(31, &KeySource::Public(xpub), true) + .unwrap(); + let deep_cj_addr = probe.address_at_index(30).unwrap(); + + let utxo_val = 7_777u64; + let utxo = Utxo { + outpoint: OutPoint { + txid: Txid::from([7u8; 32]), + vout: 0, + }, + txout: TxOut { + value: utxo_val, + script_pubkey: deep_cj_addr.script_pubkey(), + }, + address: deep_cj_addr.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + + let core = platform_wallet::changeset::CoreChangeSet { + new_utxos: vec![utxo], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + &LoadCtx::strict(), + ) + .unwrap(); + + // Balance is exact. + assert_eq!( + wallet_info.balance.total(), + utxo_val, + "CoinJoin deep-index balance must be exact" + ); + + // The CoinJoin pool was extended to include the deep-index address. + let funds_post = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let cj_pool = funds_post + .managed_account_type() + .address_pools() + .into_iter() + .find(|p| p.pool_type == AddressPoolType::External) + .expect("CoinJoin topology: must have an External pool"); + assert_eq!( + cj_pool.address_at_index(30).as_ref(), + Some(&deep_cj_addr), + "CoinJoin pool must be extended to cover deep-index address at idx 30" + ); + } + + /// In-window restored UTXO: an address already covered by the eager + /// derivation (idx 3, inside `0..=gap_limit-1`) must still be marked + /// `used` during rehydration. The discovery scan never visits in-window + /// addresses, so without an explicit mark pass a funded address would keep + /// `used = false` and could later be handed out as a fresh receive address. + #[test] + fn rehydration_marks_in_window_restored_address_used() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + + let wallet = Wallet::from_seed_bytes( + [5u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // External idx 3 — inside the eager window, so NOT in the discovery set. + let in_window: Address = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(4, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(3).unwrap() + }; + + let core = platform_wallet::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([1u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 12_345, + script_pubkey: in_window.script_pubkey(), + }, + address: in_window.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + &LoadCtx::strict(), + ) + .unwrap(); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + let info = external + .address_info(&in_window) + .expect("in-window address must be present in the pool"); + assert!( + info.is_used(), + "in-window restored UTXO address must be marked used" + ); + assert!( + external.used_indices.contains(&3), + "used_indices must record the in-window slot" + ); + assert_eq!( + external.highest_used, + Some(3), + "highest_used must reflect the in-window slot" + ); + } + + /// Privacy / address-reuse: a previously-used address whose UTXO was + /// SINCE SPENT must still come back marked `used` when the caller passes + /// it via `used_pool_addresses`. + /// Without it the address resets to `used = false` and could be handed + /// out again as a fresh receive address. The used flag must survive even + /// though the UTXO is gone (`spent_utxos` cancels `new_utxos` → zero + /// balance), proving it is NOT just a side effect of a live UTXO. Covers + /// an in-window slot (idx 5) and a deeper slot the horizon walk resolves + /// (idx 30), and asserts the empty-snapshot baseline does NOT mark them. + #[test] + fn rehydration_used_state_survives_spent_utxo() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use platform_wallet::changeset::CoreChangeSet; + + let wallet = Wallet::from_seed_bytes( + [42u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + + let funds_type = ManagedWalletInfo::from_wallet(&wallet, 1) + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + let derive = |index: u32| -> Address { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(index + 1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(index).unwrap() + }; + let in_window_used = derive(5); + let deep_used = derive(30); + + // The in-window address received funds (new_utxos) that were later + // spent (spent_utxos) — so it carries NO unspent UTXO. Exactly the + // reuse hazard: zero balance, yet the address must stay `used`. + let spent = Utxo { + outpoint: OutPoint { + txid: Txid::from([1u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 50_000, + script_pubkey: in_window_used.script_pubkey(), + }, + address: in_window_used.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + let core = CoreChangeSet { + new_utxos: vec![spent.clone()], + spent_utxos: vec![spent], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + // Pool used-state carried for both addresses (the reuse guard the + // SQLite persister feeds via `core_state::load_used_addresses`). Single + // funds account, so a `None` owner routes to it. + let used_core_addresses: std::collections::HashMap> = + [in_window_used.clone(), deep_used.clone()] + .into_iter() + .map(|a| (a, None)) + .collect(); + + // Baseline: drop the pool used-state (empty) — the spent-out address + // resets to unused (the pre-fix behaviour, and the reuse hazard). + { + let mut baseline = ManagedWalletInfo::from_wallet(&wallet, 1); + apply_persisted_core_state( + &mut baseline, + &manifest, + &core, + &Default::default(), + &Default::default(), + &LoadCtx::strict(), + ) + .unwrap(); + let funds = baseline + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + assert!( + !external + .address_info(&in_window_used) + .map(|i| i.is_used()) + .unwrap_or(false), + "without pool used-state a spent-out address resets to unused" + ); + } + + // With the persisted used-state passed as `used_pool_addresses`, both + // come back used. + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &used_core_addresses, + &LoadCtx::strict(), + ) + .unwrap(); + + // The spent UTXO contributes no balance — the used flag is NOT a + // side effect of a live UTXO. + assert_eq!( + wallet_info.balance.total(), + 0, + "the spent UTXO must not contribute balance" + ); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + assert!( + external + .address_info(&in_window_used) + .expect("in-window used address present") + .is_used(), + "in-window spent-out address must be restored as used" + ); + assert!(external.used_indices.contains(&5), "idx 5 recorded used"); + assert!( + external + .address_info(&deep_used) + .expect("deep used address derived into pool") + .is_used(), + "deep spent-out address must be derived + restored as used" + ); + assert!(external.used_indices.contains(&30), "idx 30 recorded used"); + assert_eq!( + external.highest_used, + Some(30), + "highest_used must reflect the deepest restored used slot" + ); + } + + /// Regression (mark↔refill fixpoint): a previously-used address in the + /// "wedge zone" — past the discovery horizon but within reach of the + /// gap refill — must come back `used`. With used addresses at idx 20 + /// (in the eager window) and idx 45 (gap 30): the discovery walk + /// excludes in-window addresses from `unresolved`, so nothing anchors + /// the horizon past 30 and idx 45 is never scanned; marking idx 20 then + /// makes `maintain_gap_limit` derive out to 20+30=50, which brings the + /// idx-45 address into the pool. A single mark-then-refill pass left it + /// there with `used = false` — pool-visible as a FRESH address, handed + /// out again, and its stale `used = false` persisted back over the + /// store's `is_used = true` on the next pool snapshot. The fixpoint + /// re-marks after every refill until nothing new resolves. + #[test] + fn rehydration_wedge_zone_used_address_marked_after_refill() { + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Address; + + let wallet = Wallet::from_seed_bytes( + [61u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + let derive = |index: u32| -> Address { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(index + 1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(index).unwrap() + }; + // Reachable multi-device state: this device saw idx 20 used; + // another device (same mnemonic) handed out and used idx 45. + let in_window_used = derive(20); + let wedge_used = derive(45); + + // No UTXOs at all — only the persisted pool used-state. Single funds + // account, so a `None` owner routes to it. + let core = platform_wallet::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + let used: std::collections::HashMap> = + [in_window_used.clone(), wedge_used.clone()] + .into_iter() + .map(|a| (a, None)) + .collect(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &used, + &LoadCtx::strict(), + ) + .unwrap(); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + assert!( + external + .address_info(&in_window_used) + .expect("in-window used address present") + .is_used(), + "in-window used address must be restored as used" + ); + let wedge_info = external + .address_info(&wedge_used) + .expect("wedge-zone address must be derived into the pool by the refill"); + assert!( + wedge_info.is_used(), + "wedge-zone previously-used address must be re-marked used, \ + not left pool-visible as fresh" + ); + assert!(external.used_indices.contains(&45), "idx 45 recorded used"); + assert_eq!( + external.highest_used, + Some(45), + "highest_used must reflect the wedge-zone slot" + ); + // And the window is refilled past the re-marked slot. + assert!( + external.highest_generated >= Some(45 + DEFAULT_EXTERNAL_GAP_LIMIT), + "gap window must extend past the re-marked wedge slot (got {:?})", + external.highest_generated, + ); + } + + /// Documented limitation (solution b): a legitimately-owned but + /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx + /// <= 30 — is left unresolved because the discovery horizon (gap_limit + /// past the deepest match) never advances far enough to reach it. The + /// wallet total stays exact; only the per-address view is incomplete + /// until the next sync (a `tracing::warn!` records the deferral). + #[test] + fn rehydration_deep_sparse_utxo_left_unresolved_total_exact() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let wallet = Wallet::from_seed_bytes( + [21u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // External idx 45 — past the eager window AND past the initial scan + // window (horizon = gap_limit = 30 with no nearer match to extend it). + let sparse_deep: Address = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(46, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(45).unwrap() + }; + + let value = 500_000u64; + let core = platform_wallet::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([4u8; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: sparse_deep.script_pubkey(), + }, + address: sparse_deep.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + // Strict, deliberately: an unresolved address cannot be told apart + // from a legitimately sparse wallet, so it degrades but never fails. + let ctx = LoadCtx::strict(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + &ctx, + ) + .expect("an unresolved address must never brick a strict load"); + let degradation = ctx.degradation(); + assert!(degradation.degraded); + assert_eq!( + degradation.by_site.get(&LoadSite::UnresolvedUtxoAddress), + Some(&1), + "the deep-sparse address must be counted: {:?}", + degradation.by_site + ); + + // The wallet total is exact regardless (a sum over the UTXO set). + assert_eq!(wallet_info.balance.total(), value); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + assert!( + !external.contains_address(&sparse_deep), + "deep-sparse idx 45 must be left unresolved (absent from the pool)" + ); + + // Per-address view: the deep-sparse UTXO is not pool-visible yet. + let pool_addresses: HashSet
= pools + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, 0, + "the deep-sparse UTXO is deferred — not pool-visible until next sync" + ); + assert!(visible < value, "per-address visible < exact total"); + } + + /// Topology guard: a wallet with persisted UTXOs but NO funds-bearing + /// account cannot hold them — fail closed with + /// `RehydrationTopologyUnsupported` (reporting the persisted count) rather + /// than reconstruct a silent zero balance. + #[test] + fn rehydration_utxos_without_funds_account_errors() { + use dashcore::address::Payload; + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::hashes::Hash; + use dashcore::{OutPoint, PubkeyHash, Txid}; + use key_wallet::account::AccountType; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::BTreeSet; + + // Keys-only wallet: a single IdentityRegistration account, no funds. + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + Some(vec![AccountType::IdentityRegistration]), + ); + let wallet = Wallet::from_seed_bytes([23u8; 64], Network::Testnet, opts).unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + wallet_info.accounts.all_funding_accounts().is_empty(), + "fixture must have NO funds-bearing account" + ); + + let addr = Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([9u8; 20])), + ); + let core = platform_wallet::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([2u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 800_000, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + let err = apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + &LoadCtx::strict(), + ) + .expect_err("must fail closed when no funds account can hold the UTXOs"); + match err { + WalletStorageError::MissingAccount { wallet_id: id } => { + assert_eq!( + id, wallet_info.wallet_id, + "wallet_id must match the rehydrated wallet" + ); + } + other => panic!("expected MissingAccount, got {other:?}"), + } + } + + /// Companion to the topology guard: the same keys-only wallet with an + /// EMPTY persisted UTXO set is `Ok` — there is nothing to hold, so the + /// guard does not trip. + #[test] + fn rehydration_no_funds_account_empty_utxos_ok() { + use key_wallet::account::AccountType; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use std::collections::BTreeSet; + + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + Some(vec![AccountType::IdentityRegistration]), + ); + let wallet = Wallet::from_seed_bytes([24u8; 64], Network::Testnet, opts).unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!(wallet_info.accounts.all_funding_accounts().is_empty()); + + let core = platform_wallet::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + &LoadCtx::strict(), + ) + .expect("empty UTXO set must be Ok even with no funds account"); + } + + /// Regression: a `last_applied_chain_lock` carried in the persisted + /// `CoreChangeSet` must be restored onto the rehydrated wallet + /// metadata. Without it, the asset-lock-resume CL-from-metadata + /// fallback (`proof.rs`) cannot fire at app launch and a pre-restart + /// chain-locked asset lock can't produce a proof until SPV re-applies + /// a fresh chainlock. Fails (`None != Some`) if the apply step drops it. + #[test] + fn rehydration_restores_last_applied_chain_lock() { + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::hashes::Hash; + use dashcore::BlockHash; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let wallet = Wallet::from_seed_bytes( + [5u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + wallet_info.metadata.last_applied_chain_lock.is_none(), + "fresh watch-only skeleton starts with no chain lock" + ); + + let cl = ChainLock { + block_height: 123_456, + block_hash: BlockHash::from_byte_array([7u8; 32]), + signature: [9u8; 96].into(), + }; + let core = platform_wallet::changeset::CoreChangeSet { + last_applied_chain_lock: Some(cl.clone()), + ..Default::default() + }; + + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + &LoadCtx::strict(), + ) + .unwrap(); + + assert_eq!( + wallet_info.metadata.last_applied_chain_lock.as_ref(), + Some(&cl), + "persisted last_applied_chain_lock must be restored onto wallet metadata" + ); + } + + /// A `Default` watch-only wallet with its first funds account's external + /// pool high-water marks overwritten (both fields are `pub` upstream), as + /// a pool whose persisted state implies an oversized refill would look. + struct GapRefillFixture { + wallet_info: key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, + manifest: Vec, + /// External index 0 — inside the eager window, so it marks used + /// without any discovery derivation and drives the mark/refill + /// fixpoint straight into the guard. + marked: key_wallet::Address, + generated_before: Option, + gap_limit: u32, + } + + fn gap_refill_fixture( + seed: u8, + highest_used: u32, + highest_generated: Option, + ) -> GapRefillFixture { + use key_wallet::managed_account::address_pool::AddressPool; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let wallet = Wallet::from_seed_bytes( + [seed; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + let marked = first_external_address(&wallet_info, &manifest); + + let (generated_before, gap_limit) = { + let mut funding = wallet_info.accounts.all_funding_accounts_mut(); + let account = funding.first_mut().expect("a funds account"); + let mut pools = account.managed_account_type_mut().address_pools_mut(); + let pool: &mut AddressPool = pools + .iter_mut() + .find(|p| p.is_external()) + .expect("an external pool"); + pool.highest_used = Some(highest_used); + if let Some(generated) = highest_generated { + pool.highest_generated = Some(generated); + } + (pool.highest_generated, pool.gap_limit) + }; + + GapRefillFixture { + wallet_info, + manifest, + marked, + generated_before, + gap_limit, + } + } + + fn ensure_derived_failure_fixture(seed: u8) -> GapRefillFixture { + use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let wallet = Wallet::from_seed_bytes( + [seed; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let (marked, generated_before, gap_limit) = { + let mut funding = wallet_info.accounts.all_funding_accounts_mut(); + let account = funding.first_mut().expect("a funds account"); + let account_type = account.managed_account_type().to_account_type(); + let key_source = KeySource::Public( + manifest + .iter() + .find(|entry| entry.account_type == account_type) + .expect("manifest entry") + .account_xpub, + ); + let mut pools = account.managed_account_type_mut().address_pools_mut(); + let pool: &mut AddressPool = pools + .iter_mut() + .find(|pool| pool.is_external()) + .expect("an external pool"); + let mut probe = pool.clone(); + let marked = probe + .generate_addresses(1, &key_source, true) + .expect("derive the next address") + .pop() + .expect("one derived address"); + let missing_index = probe.highest_generated.expect("derived index"); + pool.highest_generated = Some(missing_index); + (marked, pool.highest_generated, pool.gap_limit) + }; + + GapRefillFixture { + wallet_info, + manifest, + marked, + generated_before, + gap_limit, + } + } + + /// A pool poised at the non-hardened child-index boundary (`2^31`): the + /// implied cost stays tiny (a handful of addresses), so the size cap + /// would wave it through, but the target it implies is deeper than any + /// index derivable from a public xpub. + fn gap_limit_boundary_fixture(seed: u8) -> GapRefillFixture { + gap_refill_fixture( + seed, + MAX_NORMAL_CHILD_INDEX - 5, + Some(MAX_NORMAL_CHILD_INDEX - 10), + ) + } + + /// The first funds account's external pool. + fn external_pool_state( + wallet_info: &key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, + ) -> Option { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .expect("a funds account") + .managed_account_type() + .address_pools() + .into_iter() + .find(|p| p.is_external()) + .expect("an external pool") + .highest_generated + } + + fn rehydrate_fixture( + fixture: &mut GapRefillFixture, + ctx: &LoadCtx, + ) -> Result<(), WalletStorageError> { + let wallet_id = fixture.wallet_info.wallet_id; + let restored = std::slice::from_ref(&fixture.marked); + let manifest = fixture.manifest.clone(); + let mut funding = fixture.wallet_info.accounts.all_funding_accounts_mut(); + extend_pools_for_restored_addresses(funding[0], &manifest, restored, wallet_id, ctx) + } + + /// A pool whose `highest_used` sits far past `highest_generated` implies a + /// refill of that whole span. Rehydration must reject it on arithmetic + /// alone rather than let `maintain_gap_limit` allocate its way to an OOM. + #[test] + fn gap_refill_over_cap_is_fatal_under_strict() { + let mut fixture = gap_refill_fixture(21, MAX_REHYDRATION_GAP_REFILL + 1_000, None); + let err = rehydrate_fixture(&mut fixture, &LoadCtx::strict()) + .expect_err("an over-cap refill must fail a strict load"); + + assert!( + matches!( + err, + WalletStorageError::RehydrationGapLimitRefillTooLarge { + implied, + cap: MAX_REHYDRATION_GAP_REFILL, + .. + } if implied > MAX_REHYDRATION_GAP_REFILL + ), + "the cap must report the refill it refused and the cap it applied: {err:?}" + ); + } + + /// Recovery defers the same pool instead of failing — and, the point of + /// the pre-flight placement, generates nothing at all on the way out. + #[test] + fn gap_refill_over_cap_is_deferred_under_recovery() { + let mut fixture = gap_refill_fixture(22, MAX_REHYDRATION_GAP_REFILL + 1_000, None); + let generated_before = fixture.generated_before; + let ctx = LoadCtx::recovery(); + rehydrate_fixture(&mut fixture, &ctx).expect("recovery must defer, not fail"); + + let degradation = ctx.degradation(); + assert!(degradation.degraded); + assert_eq!( + degradation.by_site.get(&LoadSite::RehydrationGapLimit), + Some(&1), + "the deferred refill must be counted at its own site: {:?}", + degradation.by_site + ); + assert_eq!( + external_pool_state(&fixture.wallet_info), + generated_before, + "a rejected refill must derive nothing" + ); + } + + #[test] + fn ensure_derived_failure_is_strictly_fatal_and_recovery_is_deferred() { + let mut strict_fixture = ensure_derived_failure_fixture(24); + let missing_index = strict_fixture.generated_before.expect("missing index"); + let err = rehydrate_fixture(&mut strict_fixture, &LoadCtx::strict()) + .expect_err("strict must reject the inconsistent pool high-water mark"); + assert!(matches!( + err, + WalletStorageError::RehydrationEnsureDerivedFailed { index } + if index == missing_index + )); + + let mut recovery_fixture = ensure_derived_failure_fixture(25); + let generated_before = recovery_fixture.generated_before; + let ctx = LoadCtx::recovery(); + rehydrate_fixture(&mut recovery_fixture, &ctx) + .expect("recovery must defer the inconsistent pool"); + let degradation = ctx.degradation(); + assert_eq!( + degradation.by_site.get(&LoadSite::RehydrationEnsureDerived), + Some(&1) + ); + assert_eq!(degradation.by_site.len(), 1); + assert_eq!( + external_pool_state(&recovery_fixture.wallet_info), + generated_before + ); + } + + /// A pool whose refill target crosses the `2^31` non-hardened + /// child-index boundary clears the size cap — the implied span is a + /// handful of addresses — so only the validity pre-flight refuses it. + #[test] + fn gap_refill_target_past_normal_child_boundary_is_fatal_under_strict() { + let mut fixture = gap_limit_boundary_fixture(26); + let err = rehydrate_fixture(&mut fixture, &LoadCtx::strict()) + .expect_err("an out-of-range refill target must fail a strict load"); + + assert!( + matches!( + err, + WalletStorageError::RehydrationGapLimitTargetOutOfRange { + highest_used: Some(highest), + .. + } if highest == MAX_NORMAL_CHILD_INDEX - 5 + ), + "the guard must name the pool state it refused: {err:?}" + ); + } + + /// Recovery defers the same pool instead of failing, and — the point of + /// refusing before the call rather than after it — derives nothing on + /// the way out instead of walking up to the boundary first. + #[test] + fn gap_refill_target_past_normal_child_boundary_is_deferred_under_recovery() { + let mut fixture = gap_limit_boundary_fixture(27); + let generated_before = fixture.generated_before; + let ctx = LoadCtx::recovery(); + rehydrate_fixture(&mut fixture, &ctx).expect("recovery must defer, not fail"); + + let degradation = ctx.degradation(); + assert!(degradation.degraded); + assert_eq!( + degradation + .by_site + .get(&LoadSite::RehydrationMaintainGapLimit), + Some(&1), + "the deferred refill failure must be counted at its own site: {:?}", + degradation.by_site + ); + assert_eq!( + external_pool_state(&fixture.wallet_info), + generated_before, + "a refused refill must derive nothing" + ); + } + + /// A pool whose `highest_used` sits within one gap window of `u32::MAX` + /// drives upstream's raw `highest + gap_limit` over the end of the type. + /// The implied span is a handful of addresses, so the size cap passes it + /// through; only a checked pre-flight stops it, and it must stop it as an + /// error rather than the panic no load policy can catch. + #[test] + fn gap_refill_target_overflow_is_fatal_under_strict() { + let mut fixture = gap_refill_fixture(28, u32::MAX - 5, Some(u32::MAX - 10)); + let err = rehydrate_fixture(&mut fixture, &LoadCtx::strict()) + .expect_err("an unrepresentable refill target must fail a strict load"); + + assert!( + matches!( + err, + WalletStorageError::RehydrationGapLimitTargetOutOfRange { + highest_used: Some(highest), + .. + } if highest == u32::MAX - 5 + ), + "the guard must name the pool state it refused: {err:?}" + ); + } + + /// Recovery defers the same pool instead of failing — the contract that + /// an upstream panic would have broken outright — and derives nothing. + #[test] + fn gap_refill_target_overflow_is_deferred_under_recovery() { + let mut fixture = gap_refill_fixture(29, u32::MAX - 5, Some(u32::MAX - 10)); + let generated_before = fixture.generated_before; + let ctx = LoadCtx::recovery(); + rehydrate_fixture(&mut fixture, &ctx).expect("recovery must defer, not fail"); + + let degradation = ctx.degradation(); + assert!(degradation.degraded); + assert_eq!( + degradation + .by_site + .get(&LoadSite::RehydrationMaintainGapLimit), + Some(&1), + "the refused refill must be counted at the gap-maintenance site: {:?}", + degradation.by_site + ); + assert_eq!( + external_pool_state(&fixture.wallet_info), + generated_before, + "a refused refill must derive nothing" + ); + } + + /// `highest_generated` is an index, so `None` means the pool holds no + /// addresses at all — not one at index 0. Costing it as index 0 + /// under-counts the work by one and names a generated address that does + /// not exist, in a guard whose own contract is to over-estimate. + #[test] + fn nothing_generated_costs_the_whole_window() { + let refill = ImpliedRefill::of(None, None, 20).expect("a representable target"); + assert_eq!(refill.target, 19, "indices 0..=19 must exist"); + assert_eq!(refill.already_generated, 0); + assert_eq!(refill.implied, 20, "twenty addresses, not nineteen"); + } + + /// The boundary the previous arithmetic blurred: a pool generated + /// through index 0 holds exactly one address, one more than an empty + /// one, and the two must cost differently. + #[test] + fn a_pool_generated_through_index_zero_holds_one_address() { + let empty = ImpliedRefill::of(None, None, 20).expect("a representable target"); + let one = ImpliedRefill::of(None, Some(0), 20).expect("a representable target"); + assert_eq!(one.already_generated, 1); + assert_eq!(one.implied, 19); + assert_eq!( + empty.implied, + one.implied + 1, + "an empty pool must cost exactly one address more" + ); + } + + /// A deep pool already generated up to its used index owes one gap + /// window, whatever its absolute depth. + #[test] + fn a_deep_pool_owes_only_its_gap_window() { + let refill = + ImpliedRefill::of(Some(50_000), Some(49_990), 20).expect("a representable target"); + assert_eq!(refill.target, 50_020); + assert_eq!(refill.already_generated, 49_991); + assert_eq!(refill.implied, 30); + } + + /// Every refill target upstream would compute with raw arithmetic, at the + /// four boundaries where the raw form breaks: an empty pool with no gap + /// window (upstream's `gap_limit - 1` underflow), a used index one gap + /// window from the end of the type (its `highest + gap_limit` overflow), + /// a target landing exactly on the first hardened index, and the deepest + /// target that is still legal. + #[test] + fn unrepresentable_refill_targets_are_rejected() { + let rejected = [ + (None, 0), + (Some(u32::MAX), 1), + (Some(MAX_NORMAL_CHILD_INDEX), 1), + ]; + for (highest_used, gap_limit) in rejected { + let err = ImpliedRefill::of(highest_used, None, gap_limit).expect_err( + "a target that does not fit a non-hardened child index must be refused", + ); + assert!( + matches!( + err, + WalletStorageError::RehydrationGapLimitTargetOutOfRange { + highest_used: got_used, + gap_limit: got_gap, + } if got_used == highest_used && got_gap == gap_limit + ), + "the refusal must carry the inputs it refused: {err:?}" + ); + } + + let deepest_legal = ImpliedRefill::of(Some(MAX_NORMAL_CHILD_INDEX - 1), None, 1) + .expect("the last non-hardened index is a legal target"); + assert_eq!(deepest_legal.target, MAX_NORMAL_CHILD_INDEX); + } + + /// The cap bounds the refill's *span*, not the depth it starts from: a + /// legitimately deep pool that is already generated up to its used index + /// implies one gap window of work and must refill normally. + #[test] + fn deep_but_shallow_span_gap_refill_is_not_capped() { + let mut fixture = gap_refill_fixture(23, 50_000, Some(49_990)); + let expected = 50_000 + fixture.gap_limit; + let ctx = LoadCtx::strict(); + rehydrate_fixture(&mut fixture, &ctx).expect("a one-window refill must not be capped"); + + assert!(!ctx.degradation().degraded); + assert_eq!( + external_pool_state(&fixture.wallet_info), + Some(expected), + "the refill must reach one gap window past the used index" + ); + } + + /// A restored UTXO whose transaction carried an InstantSend lock must come + /// back instant-locked, not wait for the next sync to re-learn it. The + /// persisted `core_instant_locks` rows arrive in + /// `CoreChangeSet::instant_locks_for_non_final_records`, and replaying them + /// has to happen AFTER the UTXO restore — `mark_instant_send_utxos` marks + /// the UTXOs it can find, so calling it first would insert the txid and + /// mark nothing. + #[test] + fn rehydration_restores_instant_send_locks_onto_restored_utxos() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + + let seed = [37u8; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let bip44_type = wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == bip44_type) + .map(|e| e.account_xpub) + .expect("account xpub in manifest"); + let address: Address = { + let mut pool = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + pool.generate_addresses(1, &KeySource::Public(xpub), true) + .unwrap(); + pool.address_at_index(0).unwrap() + }; + + let txid = Txid::from([0x5Au8; 32]); + let outpoint = OutPoint { txid, vout: 0 }; + // `is_instantlocked: false` is the persisted shape: the flag is not a + // stored column, it is re-derived from the `core_instant_locks` row. + let utxo = Utxo { + outpoint, + txout: TxOut { + value: 12_345, + script_pubkey: address.script_pubkey(), + }, + address, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + + // A real IS-lock always carries at least one input; `default()` leaves + // the vec empty. + let lock = InstantLock { + inputs: vec![OutPoint { + txid: Txid::from([0xA1u8; 32]), + vout: 7, + }], + txid, + ..Default::default() + }; + + let core = platform_wallet::changeset::CoreChangeSet { + new_utxos: vec![utxo], + instant_locks_for_non_final_records: [(txid, lock)].into_iter().collect(), + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + &LoadCtx::strict(), + ) + .unwrap(); + + assert!( + wallet_info.instant_send_locks().contains(&txid), + "the persisted InstantSend lock must be replayed onto the wallet" + ); + let restored = wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .and_then(|a| a.utxos.get(&outpoint)) + .expect("the restored UTXO must be present on the BIP44 account"); + assert!( + restored.is_instantlocked, + "the restored UTXO must carry instant-locked status, not wait for the next sync" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 04e251496f0..7213558f3b4 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -1,15 +1,50 @@ -//! `account_registrations` + `account_address_pools` writers + readers. +//! `account_registrations` writer + keyless reader (platform-payment +//! registrations and the rehydration account-manifest oracle), including +//! provider key-material accounts. use std::collections::BTreeMap; +use key_wallet::account::AccountType; use key_wallet::bip32::ExtendedPubKey; use rusqlite::{params, Connection, Transaction}; -use platform_wallet::changeset::{AccountAddressPoolEntry, AccountRegistrationEntry}; +use platform_wallet::changeset::{ + AccountRegistrationEntry, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, +}; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob; +use crate::sqlite::schema::blob::impl_persistable_blob; + +// PUBLIC material only: the account-registration xpub manifest reaching +// the `account_xpub_bytes` blob column. +impl_persistable_blob!(AccountRegistrationEntry); + +// PUBLIC material only: the provider account's own-curve extended PUBLIC key +// (BLS operator / EdDSA platform node) reaching the same blob column. The type +// has no field that could carry signing material. +impl_persistable_blob!(ProviderKeyAccountEntry); + +/// The persisted account manifest of one wallet: the secp256k1 accounts and +/// the provider key-material accounts, which carry a non-secp256k1 extended +/// public key and so cannot share one entry type. +#[derive(Debug, Clone, Default)] +pub struct AccountManifest { + /// secp256k1 accounts, ordered by `(account_type, account_index)`. + pub ecdsa: Vec, + /// BLS operator-key / EdDSA platform-node-key accounts, ordered by + /// `account_type`. + pub provider: Vec, +} + +impl AccountManifest { + /// True when the wallet registered no account of either kind. + pub fn is_empty(&self) -> bool { + self.ecdsa.is_empty() && self.provider.is_empty() + } +} /// Decoded `platform_payment` account registration: the DIP-17 account /// index and its extended public key, recovered from the bincode-serde @@ -19,40 +54,55 @@ pub(crate) type PlatformPaymentRegistration = (u32, ExtendedPubKey); /// One `platform_payment` registration row decoded into /// `(account_index, xpub)`. fn decode_platform_payment_row( - account_index: i64, + typed_index: i64, + typed_key_class: i64, xpub_bytes: &[u8], ) -> Result { - let account_index = - u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { - field: "account_registrations.account_index", - value: account_index as u64, - target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, - })?; + let typed_index = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.account_index", + typed_index, + )?; + let typed_key_class = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.key_class", + typed_key_class, + )?; let entry: AccountRegistrationEntry = blob::decode(xpub_bytes)?; - Ok((account_index, entry.account_xpub)) + // Callers select `WHERE account_type = 'platform_payment'`, so the decoded + // blob must agree: a PlatformPayment account at the same index AND key_class. + // key_class is a real discriminator — two PlatformPayment accounts can share + // `(account_type, account_index)` and differ only here (the widened PK exists + // for exactly that) — so cross-check it like `load_state` does, or the oracle + // could hand back a row keyed by a different key class than its blob names. + if account_type_db_label(&entry.account_type) != "platform_payment" + || account_index(&entry.account_type) != typed_index + || account_key_class(&entry.account_type) != typed_key_class + { + return Err(WalletStorageError::AccountRegistrationEntryMismatch); + } + Ok((typed_index, entry.account_xpub)) } -/// Every `platform_payment` account registration for one wallet, decoded -/// into `(account_index, xpub)`. The xpub is recovered from the -/// bincode-serde `AccountRegistrationEntry` `apply_registrations` writes -/// into `account_xpub_bytes`. +/// Every `platform_payment` registration for one wallet, decoded into +/// `(account_index, xpub)`. #[cfg(any(test, feature = "__test-helpers"))] pub(crate) fn list_platform_payment_registrations( conn: &Connection, wallet_id: &WalletId, ) -> Result, WalletStorageError> { let mut stmt = conn.prepare( - "SELECT account_index, account_xpub_bytes FROM account_registrations \ + "SELECT account_index, key_class, length(account_xpub_bytes), account_xpub_bytes \ + FROM account_registrations \ WHERE wallet_id = ?1 AND account_type = 'platform_payment' \ ORDER BY account_index", )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, Vec>(1)?)) - })?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut out = Vec::new(); - for r in rows { - let (idx, bytes) = r?; - out.push(decode_platform_payment_row(idx, &bytes)?); + while let Some(row) = rows.next()? { + let idx: i64 = row.get(0)?; + let key_class: i64 = row.get(1)?; + blob::check_size(row.get::<_, i64>(2)?)?; + let bytes: Vec = row.get(3)?; + out.push(decode_platform_payment_row(idx, key_class, &bytes)?); } Ok(out) } @@ -63,34 +113,59 @@ pub(crate) fn list_platform_payment_registrations( /// query. pub(crate) fn all_platform_payment_registrations( conn: &Connection, -) -> Result>, WalletStorageError> { +) -> Result< + BTreeMap, WalletStorageError>>, + WalletStorageError, +> { let mut stmt = conn.prepare( - "SELECT wallet_id, account_index, account_xpub_bytes FROM account_registrations \ + "SELECT length(wallet_id), wallet_id, account_index, key_class, \ + length(account_xpub_bytes), account_xpub_bytes \ + FROM account_registrations \ WHERE account_type = 'platform_payment' \ ORDER BY wallet_id, account_index", )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, Vec>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, Vec>(2)?, - )) - })?; - let mut out: BTreeMap> = BTreeMap::new(); - for r in rows { - let (wid_bytes, idx, bytes) = r?; - let wallet_id = <[u8; 32]>::try_from(wid_bytes.as_slice()).map_err(|_| { - WalletStorageError::InvalidWalletIdLength { - actual: wid_bytes.len(), + let mut rows = stmt.query([])?; + let mut out: BTreeMap, WalletStorageError>> = + BTreeMap::new(); + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get::<_, i64>(0)?, 32, "account_registrations.wallet_id")?; + let wid_bytes: Vec = row.get(1)?; + let idx: i64 = row.get(2)?; + let key_class: i64 = row.get(3)?; + let payload_width: i64 = row.get(4)?; + let bytes: Vec = row.get(5)?; + // An id that is not 32 bytes belongs to no wallet, so it stays + // file-fatal; everything after it is attributable to one. + let wallet_id = super::id32("account_registrations.wallet_id", &wid_bytes)?; + let decoded = blob::check_size(payload_width) + .and_then(|()| decode_platform_payment_row(idx, key_class, &bytes)); + match decoded { + // A wallet already recorded as failed keeps its first cause; + // its remaining rows cannot change the outcome. + Ok(decoded) => { + if let Ok(rows) = out.entry(wallet_id).or_insert_with(|| Ok(Vec::new())) { + rows.push(decoded); + } } - })?; - out.entry(wallet_id) - .or_default() - .push(decode_platform_payment_row(idx, &bytes)?); + Err(err) => { + let slot = out.entry(wallet_id).or_insert_with(|| Ok(Vec::new())); + if slot.is_ok() { + *slot = Err(err); + } + } + } } Ok(out) } +/// Persist ordinary secp256k1 account registrations for one wallet. +/// +/// # Errors +/// +/// Returns [`WalletStorageError::ProviderKeyAccountEntryMismatch`] if a +/// provider key-material account is submitted through this ECDSA writer. +/// Returns another [`WalletStorageError`] if an entry cannot be encoded or the +/// database write fails. pub fn apply_registrations( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -99,77 +174,469 @@ pub fn apply_registrations( 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", - )?; + if entries + .iter() + .any(|entry| is_provider_key_material(&entry.account_type)) + { + return Err(WalletStorageError::ProviderKeyAccountEntryMismatch); + } + let mut stmt = tx.prepare_cached(UPSERT_ACCOUNT_SQL)?; 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, - ])?; + upsert_account_row( + &mut stmt, + wallet_id, + &entry.account_type, + blob::encode(entry)?, + )?; } Ok(()) } -pub fn apply_pools( +fn is_provider_key_material(account_type: &AccountType) -> bool { + match account_type { + AccountType::ProviderOperatorKeys => true, + AccountType::ProviderPlatformKeys => true, + AccountType::Standard { .. } => false, + AccountType::CoinJoin { .. } => false, + AccountType::IdentityRegistration => false, + AccountType::IdentityTopUp { .. } => false, + AccountType::IdentityTopUpNotBoundToIdentity => false, + AccountType::IdentityInvitation => false, + AccountType::AssetLockAddressTopUp => false, + AccountType::AssetLockShieldedAddressTopUp => false, + AccountType::ProviderVotingKeys => false, + AccountType::ProviderOwnerKeys => false, + AccountType::DashpayReceivingFunds { .. } => false, + AccountType::DashpayExternalAccount { .. } => false, + AccountType::PlatformPayment { .. } => false, + } +} + +/// Upsert for an ordinary secp256k1 `account_registrations` row. +const UPSERT_ACCOUNT_SQL: &str = "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id, account_xpub_bytes) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id) DO UPDATE SET \ + account_xpub_bytes = excluded.account_xpub_bytes"; + +/// Insert a provider key-material account without overwriting persisted bytes. +const UPSERT_PROVIDER_ACCOUNT_SQL: &str = "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id, account_xpub_bytes) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id) DO NOTHING"; + +/// Bind and execute an account-registration statement. The typed PK columns +/// derive from `account_type` here so writer and reader cross-checks stay aligned. +fn upsert_account_row( + stmt: &mut rusqlite::CachedStatement<'_>, + wallet_id: &WalletId, + account_type: &AccountType, + payload: Vec, +) -> Result<(), WalletStorageError> { + let (user_identity_id, friend_identity_id) = account_dashpay_ids(account_type); + stmt.execute(params![ + wallet_id.as_slice(), + account_type_db_label(account_type), + i64::from(account_index(account_type)), + i64::from(account_key_class(account_type)), + &user_identity_id[..], + &friend_identity_id[..], + payload, + ])?; + Ok(()) +} + +/// Persist provider key-material accounts into `account_registrations`. +/// +/// The account row uses the same PK as [`apply_registrations`]: a provider +/// account is index-less, so `(wallet_id, account_type)` plus the sentinel +/// columns is its natural key. Re-persisting an identical row is a no-op. +/// +/// # Errors +/// +/// [`WalletStorageError::ProviderKeyAccountEntryMismatch`] if an entry pairs an +/// `account_type` with the wrong curve — the same invariant the reader enforces, +/// checked here so a mis-paired entry cannot upsert onto (and destroy) the +/// ECDSA account sharing this table's PK space. +/// +/// [`WalletStorageError::ProviderKeyAccountConflict`] if two entries in one call +/// claim the same account with **different** extended public keys, or if an +/// incoming key differs from the one already persisted for that account. +pub fn apply_provider_registrations( tx: &Transaction<'_>, wallet_id: &WalletId, - entries: &[AccountAddressPoolEntry], + entries: &[ProviderKeyAccountEntry], ) -> 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", - )?; + // Validate the batch before write SQL runs so rejection cannot leave a + // sibling half-applied independently of the caller's transaction discipline. + let mut encoded: Vec<(&ProviderKeyAccountEntry, &'static str, Vec)> = + Vec::with_capacity(entries.len()); 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, - ])?; + if !provider_curve_matches_type(&entry.account_type, &entry.extended_public_key) { + return Err(WalletStorageError::ProviderKeyAccountEntryMismatch); + } + let label = account_type_db_label(&entry.account_type); + let payload = blob::encode(&ProviderKeyAccountEntry { + account_type: entry.account_type, + extended_public_key: entry.extended_public_key.clone(), + })?; + // Two entries for one account are expected — `Merge` is append-only, so + // a re-emitted registration can ride the same flush. Identical ones are + // equivalent. Two that disagree about the account's own xpub are a + // contradiction no merge semantic can resolve — one of them is wrong + // and we cannot tell which, so fail closed rather than let write order + // decide. + if let Some((_, _, prior)) = encoded.iter().find(|(_, l, _)| *l == label) { + if prior != &payload { + return Err(WalletStorageError::ProviderKeyAccountConflict { + account_type: label, + }); + } + } + encoded.push((entry, label, payload)); + } + + // Same-label payloads are byte-identical here, so either surviving index + // is equivalent. + let distinct_accounts: BTreeMap<&'static str, usize> = encoded + .iter() + .enumerate() + .map(|(index, (_, label, _))| (*label, index)) + .collect(); + for (&label, &index) in &distinct_accounts { + let (entry, _, payload) = &encoded[index]; + let conflicts = load_provider_account_payload(tx, wallet_id, &entry.account_type)? + .is_some_and(|stored| stored.as_slice() != payload.as_slice()); + if conflicts { + return Err(WalletStorageError::ProviderKeyAccountConflict { + account_type: label, + }); + } + } + + // The conflict checks and `DO NOTHING` jointly prevent account key + // material from being overwritten. + let mut account_stmt = tx.prepare_cached(UPSERT_PROVIDER_ACCOUNT_SQL)?; + for (entry, _, payload) in encoded { + upsert_account_row(&mut account_stmt, wallet_id, &entry.account_type, payload)?; } Ok(()) } -/// Single source of truth for the `account_type` TEXT-column domain -/// across `account_registrations`, `account_address_pools`, and -/// `core_derived_addresses`. +/// Return the encoded parent account payload for one provider account. +fn load_provider_account_payload( + conn: &Connection, + wallet_id: &WalletId, + account_type: &AccountType, +) -> Result>, WalletStorageError> { + let (user_identity_id, friend_identity_id) = account_dashpay_ids(account_type); + let mut stmt = conn.prepare_cached( + "SELECT length(account_xpub_bytes), account_xpub_bytes \ + FROM account_registrations \ + WHERE wallet_id = ?1 AND account_type = ?2 AND account_index = ?3 \ + AND key_class = ?4 AND user_identity_id = ?5 AND friend_identity_id = ?6", + )?; + let mut rows = stmt.query(params![ + wallet_id.as_slice(), + account_type_db_label(account_type), + i64::from(account_index(account_type)), + i64::from(account_key_class(account_type)), + &user_identity_id[..], + &friend_identity_id[..], + ])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + blob::check_size(row.get::<_, i64>(0)?)?; + Ok(Some(row.get::<_, Vec>(1)?)) +} + +/// True when the extended public key's curve is the one its account type +/// mandates. Enforced on both the write and the read path. /// -/// Mirrors every variant of [`key_wallet::account::AccountType`] -/// (writer side: [`account_type_db_label`]). The migration in -/// `migrations/V001__initial.rs` interpolates this array into the -/// `CHECK (account_type IN (...))` clause on each of those tables, so -/// an unknown label is rejected at insert time rather than landing as -/// silent garbage. The `account_type_labels_match_enum` unit test -/// below enforces set-equality between this array and the writer's -/// output — drift (a renamed/added variant) becomes a failing test, -/// not a runtime divergence between Rust and SQLite. +/// `account_type` is the discriminator that decides the curve — there is no tag +/// byte inside the payload. The FFI backend's restore side picks the same +/// discriminator (`platform-wallet-ffi::persistence`, which branches on +/// `account_type` to choose its decode), so the two backends agree on *how* a +/// provider account is identified. A [`ProviderKeyAccountEntry`] whose payload +/// carries the other curve is cross-curve confusion, not a decodable account. +fn provider_curve_matches_type(at: &AccountType, key: &ProviderKeyExtendedPubKey) -> bool { + matches!( + (at, key), + ( + AccountType::ProviderOperatorKeys, + ProviderKeyExtendedPubKey::Bls(_) + ) | ( + AccountType::ProviderPlatformKeys, + ProviderKeyExtendedPubKey::EdDSA(_) + ) + ) +} + +/// Read the provider key-material accounts of one wallet. +/// +/// Entries are ordered by `account_type`. Decode failures stay fatal; +/// Recovery skips typed-column drift and wrong-curve rows under distinct +/// degradation sites. +pub(crate) fn load_provider_state( + conn: &Connection, + wallet_id: &WalletId, + ctx: &LoadCtx, +) -> Result, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT account_type, account_index, key_class, \ + length(user_identity_id), user_identity_id, \ + length(friend_identity_id), friend_identity_id, \ + length(account_xpub_bytes), account_xpub_bytes FROM account_registrations \ + WHERE wallet_id = ?1 AND account_type IN ('provider_operator', 'provider_platform') \ + ORDER BY account_type", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + let typed_type: String = row.get(0)?; + let typed_index: i64 = row.get(1)?; + let typed_key_class: i64 = row.get(2)?; + blob::check_fixed_width( + row.get::<_, i64>(3)?, + 32, + "account_registrations.user_identity_id", + )?; + let typed_user: Vec = row.get(4)?; + blob::check_fixed_width( + row.get::<_, i64>(5)?, + 32, + "account_registrations.friend_identity_id", + )?; + let typed_friend: Vec = row.get(6)?; + blob::check_size(row.get::<_, i64>(7)?)?; + let payload: Vec = row.get(8)?; + let entry = blob::decode::(&payload)?; + + // Same typed-column cross-check the ECDSA reader applies, plus the + // curve↔account-type agreement the provider rows add. + let (blob_user, blob_friend) = account_dashpay_ids(&entry.account_type); + let typed_index = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.account_index", + typed_index, + )?; + let typed_key_class = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.key_class", + typed_key_class, + )?; + if !db_label_matches_entry(typed_type.as_str(), &entry.account_type) + || account_index(&entry.account_type) != typed_index + || account_key_class(&entry.account_type) != typed_key_class + || blob_user.as_slice() != typed_user.as_slice() + || blob_friend.as_slice() != typed_friend.as_slice() + { + ctx.tolerate( + LoadSite::ProviderKeyRegistrationDrift, + WalletStorageError::ProviderKeyAccountEntryMismatch, + )?; + continue; + } + if !provider_curve_matches_type(&entry.account_type, &entry.extended_public_key) { + ctx.tolerate( + LoadSite::ProviderKeyCurveMismatch, + WalletStorageError::ProviderKeyAccountEntryMismatch, + )?; + continue; + } + + out.push(ProviderKeyAccountEntry { + account_type: entry.account_type, + extended_public_key: entry.extended_public_key, + }); + } + Ok(out) +} + +/// Read every `account_registrations` row for `wallet_id` into a keyless +/// [`AccountManifest`] — the rehydration account-set oracle (which accounts to +/// re-derive + the per-account xpubs the wrong-account gate checks). PUBLIC +/// material only (xpub + account type), no `Wallet` minted. Each list is +/// ordered by its typed columns for determinism. Typed-column drift is fatal +/// under Strict; Recovery drops the offending registration row. A pre-split +/// `standard` row is reconciled against the precise-labelled row for the same +/// account, so a forked registration is returned once. Persisted +/// funds attributed to that missing account fall back to the first remaining +/// funds account until the next sync rebuilds per-account attribution. +pub fn load_state( + conn: &Connection, + wallet_id: &WalletId, + ctx: &LoadCtx, +) -> Result { + Ok(AccountManifest { + ecdsa: load_ecdsa_state(conn, wallet_id, ctx)?, + provider: load_provider_state(conn, wallet_id, ctx)?, + }) +} + +/// The secp256k1 half of [`load_state`]: every row whose blob is an +/// [`AccountRegistrationEntry`]. +fn load_ecdsa_state( + conn: &Connection, + wallet_id: &WalletId, + ctx: &LoadCtx, +) -> Result, WalletStorageError> { + // Select typed columns alongside the blob so we can cross-check them + // against the decoded entry — a row whose blob disagrees with its indexed + // columns is a sign of corruption or a schema bug and must be rejected + // rather than silently mis-bucketed. + // `length(account_xpub_bytes)` is read first (O(1) from the row header) so + // an oversize blob is caught before the Vec is allocated. + // The provider key-material rows are excluded by `account_type`: their + // blob is a `ProviderKeyAccountEntry` over a non-secp256k1 curve and + // would hard-error this decode. + let mut stmt = conn.prepare( + "SELECT account_type, account_index, key_class, \ + length(user_identity_id), user_identity_id, \ + length(friend_identity_id), friend_identity_id, \ + length(account_xpub_bytes), account_xpub_bytes FROM account_registrations \ + WHERE wallet_id = ?1 \ + AND account_type NOT IN ('provider_operator', 'provider_platform') \ + ORDER BY account_type, account_index, key_class, user_identity_id, friend_identity_id", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut out = Vec::new(); + // Positions in `out` of rows still carrying the pre-split `standard` + // label. `Vec::new` does not allocate until its first push, so a database + // written after the split pays one label comparison per row and nothing + // else — no allocation, and the same `out` it always returned. + let mut legacy_rows: Vec = Vec::new(); + while let Some(row) = rows.next()? { + let typed_type: String = row.get(0)?; // account_type TEXT + let typed_index: i64 = row.get(1)?; // account_index INTEGER + let typed_key_class: i64 = row.get(2)?; // key_class INTEGER + blob::check_fixed_width( + row.get::<_, i64>(3)?, + 32, + "account_registrations.user_identity_id", + )?; + let typed_user: Vec = row.get(4)?; // user_identity_id BLOB + blob::check_fixed_width( + row.get::<_, i64>(5)?, + 32, + "account_registrations.friend_identity_id", + )?; + let typed_friend: Vec = row.get(6)?; // friend_identity_id BLOB + blob::check_size(row.get::<_, i64>(7)?)?; + let payload: Vec = row.get(8)?; // account_xpub_bytes BLOB + let entry = blob::decode::(&payload)?; + // Cross-check every typed PK column vs the decoded blob so a + // corruption that passes `PRAGMA integrity_check` is still caught + // here rather than feeding a wrong account to the oracle. + let blob_index = account_index(&entry.account_type); + let blob_key_class = account_key_class(&entry.account_type); + let (blob_user, blob_friend) = account_dashpay_ids(&entry.account_type); + let typed_index = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.account_index", + typed_index, + )?; + let typed_key_class = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.key_class", + typed_key_class, + )?; + if !db_label_matches_entry(typed_type.as_str(), &entry.account_type) + || blob_index != typed_index + || blob_key_class != typed_key_class + || blob_user.as_slice() != typed_user.as_slice() + || blob_friend.as_slice() != typed_friend.as_slice() + { + ctx.tolerate( + LoadSite::AccountRegistrationDrift, + WalletStorageError::AccountRegistrationEntryMismatch, + )?; + continue; + } + if typed_type == LEGACY_STANDARD_LABEL { + legacy_rows.push(out.len()); + } + out.push(entry); + } + if legacy_rows.is_empty() { + return Ok(out); + } + reconcile_legacy_standard_rows(out, &legacy_rows, ctx) +} + +/// Collapse each pre-split `standard` row into the precise-labelled row that +/// stands for the same account. +/// +/// `V008` admits the legacy label rather than guessing which standard variant +/// such a row is, so one account can hold two rows: the writer's upsert keys +/// on `account_type`, so a post-split save INSERTS a precisely-labelled +/// sibling instead of updating the legacy row. Returning both would make this +/// reader emit one account twice and leave deduplication to a consumer that +/// cannot see why the pair exists. +/// +/// A sibling is matched on the whole typed `AccountType`, NOT on +/// `(index, key_class, identity ids)`: BIP44 and BIP32 accounts at one index +/// are different accounts that share those columns, and the coarser key would +/// fuse them — dropping a real registration and calling a healthy wallet +/// drifted. When the matched pair disagrees the legacy row is not a duplicate +/// but genuine drift (only the precise row is ever updated, so a changed xpub +/// leaves the legacy one behind), and it goes to +/// [`LoadSite::AccountRegistrationDrift`] like every other disagreement here. +/// A legacy row with no sibling is the account's only row and is kept. +fn reconcile_legacy_standard_rows( + entries: Vec, + legacy_rows: &[usize], + ctx: &LoadCtx, +) -> Result, WalletStorageError> { + let mut superseded = vec![false; entries.len()]; + for &legacy in legacy_rows { + let Some(precise) = entries + .iter() + .enumerate() + .find(|(pos, candidate)| { + !legacy_rows.contains(pos) && candidate.account_type == entries[legacy].account_type + }) + .map(|(_, candidate)| candidate) + else { + continue; + }; + if *precise != entries[legacy] { + ctx.tolerate( + LoadSite::AccountRegistrationDrift, + WalletStorageError::AccountRegistrationEntryMismatch, + )?; + } + superseded[legacy] = true; + } + Ok(entries + .into_iter() + .zip(superseded) + .filter_map(|(entry, is_superseded)| (!is_superseded).then_some(entry)) + .collect()) +} + +/// Source of truth for the `account_registrations.account_type` TEXT domain, +/// mirroring [`key_wallet::account::AccountType`]. The migrations interpolate +/// nothing: V001 freezes its own copy of this domain, because a generated-SQL +/// change breaks that migration's Refinery checksum on every database that +/// already applied it. `account_type_labels_match_enum` pins this array to +/// [`account_type_db_label`]; `account_type_labels_frozen_in_v007` pins it to +/// the frozen list in `V008__rehydration_base_schema.rs`, which rebuilt +/// `account_registrations` with the widened domain. V001 carries the narrower +/// domain `v4.2-dev` shipped, in which both standard variants share the label +/// `standard`. An upstream variant addition therefore fails a test with +/// instructions, instead of silently rewriting applied SQL. +/// +/// `Standard` maps to two distinct labels by `StandardAccountType` variant +/// (`"standard_bip44"` / `"standard_bip32"`) so BIP44 and BIP32 standard +/// accounts with the same index never collide on their shared PK columns. pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ - "standard", + "standard_bip44", + "standard_bip32", "coinjoin", "identity_registration", "identity_topup", @@ -186,30 +653,56 @@ pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ "platform_payment", ]; -/// Single source of truth for the `account_address_pools.pool_type` -/// TEXT-column domain. +/// Stable database label for an `AccountType` variant (the `Debug` impl is not +/// a stable format; this match is the contract). An added upstream variant +/// fails this match's exhaustiveness check at compile time. /// -/// Mirrors every variant of -/// [`key_wallet::managed_account::address_pool::AddressPoolType`] -/// (writer side: [`pool_type_db_label`]). See [`ACCOUNT_TYPE_LABELS`] -/// for the broader rationale and the parity-test contract. -pub(crate) const POOL_TYPE_LABELS: &[&str] = &["external", "internal", "absent", "absent_hardened"]; - -/// Stable database label for an `AccountType` variant. +/// `Standard` maps to two distinct labels by `StandardAccountType` so BIP44 +/// and BIP32 accounts with the same `index` never collapse onto the same PK. +/// The label `v4.2-dev` wrote for BOTH standard variants. /// -/// 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. +/// Its `account_type_db_label` matched `Standard { .. }` and ignored +/// `standard_account_type`, so a database created before the domain split +/// carries `standard` for BIP44 and BIP32 alike. Which one a row really is was +/// never lost -- it is inside `account_xpub_bytes` -- it is simply not +/// SQL-reachable, so no migration can resolve it. The value is therefore +/// admitted rather than rewritten; see [`db_label_matches_entry`]. +pub(crate) const LEGACY_STANDARD_LABEL: &str = "standard"; + +/// Does the stored `account_type` column agree with the blob's typed +/// `AccountType`? /// -/// Adding a variant to upstream `AccountType` makes this match -/// exhaustive-check fail at compile time, forcing an explicit label -/// decision rather than silent garbage. +/// Exact match, plus one legacy equivalence: the pre-split `standard` matches +/// EITHER standard variant. Rewriting such a row to one variant would be a +/// guess, and guessing wrong turns a row that loads today into a fatal +/// `AccountRegistrationEntryMismatch` under the default `LoadPolicy::Strict`. +/// The blob stays the sole source of truth for which variant a row is; the +/// column keeps its narrower job of filtering and key uniqueness. +pub(crate) fn db_label_matches_entry( + column: &str, + entry_type: &key_wallet::account::AccountType, +) -> bool { + if column == account_type_db_label(entry_type) { + return true; + } + column == LEGACY_STANDARD_LABEL + && matches!( + entry_type, + key_wallet::account::AccountType::Standard { .. } + ) +} + pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &'static str { - use key_wallet::account::AccountType; + use key_wallet::account::{AccountType, StandardAccountType}; match at { - AccountType::Standard { .. } => "standard", + AccountType::Standard { + standard_account_type: StandardAccountType::BIP44Account, + .. + } => "standard_bip44", + AccountType::Standard { + standard_account_type: StandardAccountType::BIP32Account, + .. + } => "standard_bip32", AccountType::CoinJoin { .. } => "coinjoin", AccountType::IdentityRegistration => "identity_registration", AccountType::IdentityTopUp { .. } => "identity_topup", @@ -227,24 +720,8 @@ pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &' } } -/// 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). +/// Numeric account index embedded in an `AccountType`, persisted in the +/// `account_registrations.account_index` column. pub(crate) fn account_index(at: &key_wallet::account::AccountType) -> u32 { use key_wallet::account::AccountType; match at { @@ -266,16 +743,393 @@ pub(crate) fn account_index(at: &key_wallet::account::AccountType) -> u32 { } } +/// Hardened `key_class` discriminator for `PlatformPayment`, persisted in the +/// `account_registrations.key_class` PK column. `0` for every other variant — +/// the sentinel "no key-class axis" value, matching the column default. +/// +/// Wildcard-free on purpose, like [`account_index`] and +/// [`account_type_db_label`]: this feeds a PRIMARY KEY column, so a variant +/// this mapper has not been taught about would be given another variant's +/// sentinel and collapse two distinct accounts onto one key — losing one of +/// them at the next write, with no error anywhere. Listing the zeros costs a +/// dozen lines and converts that silent loss into a compile error. +pub(crate) fn account_key_class(at: &key_wallet::account::AccountType) -> u32 { + use key_wallet::account::AccountType; + match at { + AccountType::PlatformPayment { key_class, .. } => *key_class, + // No key-class axis: the column's sentinel default. + AccountType::Standard { .. } + | AccountType::CoinJoin { .. } + | AccountType::IdentityRegistration + | AccountType::IdentityTopUp { .. } + | AccountType::IdentityTopUpNotBoundToIdentity + | AccountType::IdentityInvitation + | AccountType::AssetLockAddressTopUp + | AccountType::AssetLockShieldedAddressTopUp + | AccountType::ProviderVotingKeys + | AccountType::ProviderOwnerKeys + | AccountType::ProviderOperatorKeys + | AccountType::ProviderPlatformKeys + | AccountType::DashpayReceivingFunds { .. } + | AccountType::DashpayExternalAccount { .. } => 0, + } +} + +/// DashPay `(user_identity_id, friend_identity_id)` discriminator pair — the +/// real account key for `DashpayReceivingFunds` / `DashpayExternalAccount`, +/// persisted in the matching PK columns. All-zero for every non-DashPay +/// variant (no identity axis), matching the column default. +/// +/// Wildcard-free for the same reason as [`account_key_class`]: these are PK +/// columns, and an untaught variant handed the all-zero sentinel shares a key +/// with every other axis-less account at the same index. +pub(crate) fn account_dashpay_ids(at: &key_wallet::account::AccountType) -> ([u8; 32], [u8; 32]) { + use key_wallet::account::AccountType; + match at { + AccountType::DashpayReceivingFunds { + user_identity_id, + friend_identity_id, + .. + } + | AccountType::DashpayExternalAccount { + user_identity_id, + friend_identity_id, + .. + } => (*user_identity_id, *friend_identity_id), + // No identity axis: the columns' sentinel default. + AccountType::Standard { .. } + | AccountType::CoinJoin { .. } + | AccountType::IdentityRegistration + | AccountType::IdentityTopUp { .. } + | AccountType::IdentityTopUpNotBoundToIdentity + | AccountType::IdentityInvitation + | AccountType::AssetLockAddressTopUp + | AccountType::AssetLockShieldedAddressTopUp + | AccountType::ProviderVotingKeys + | AccountType::ProviderOwnerKeys + | AccountType::ProviderOperatorKeys + | AccountType::ProviderPlatformKeys + | AccountType::PlatformPayment { .. } => ([0u8; 32], [0u8; 32]), + } +} + #[cfg(test)] mod tests { use super::*; use std::collections::HashSet; - /// Exhaustive sample of every [`key_wallet::account::AccountType`] - /// variant. The match arm in the loop below uses no wildcard, so - /// an upstream-added variant becomes a compile error here and - /// forces the developer to extend the sample list (and the - /// matching arm in `account_type_db_label` / [`ACCOUNT_TYPE_LABELS`]). + /// Open an in-memory SQLite connection and run the full schema migration + /// so tests can insert rows through the production table DDL. + fn migrated_conn() -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + /// A fixed serialised extended public key for use in tests. Taken from the + /// BIP-32 mainnet test vector so it is stable and round-trips correctly. + fn test_xpub() -> key_wallet::bip32::ExtendedPubKey { + key_wallet::bip32::ExtendedPubKey::decode( + &hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D\ + 314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC\ + 2DF1050E2E8FF49C85C2", + ) + .unwrap(), + ) + .unwrap() + } + + /// `load_state` must return `AccountRegistrationEntryMismatch` when the + /// typed `account_type` column disagrees with the decoded blob. The test + /// inserts a row whose blob encodes a `PlatformPayment` entry but whose + /// column is set to `identity_registration`, then verifies the mismatch + /// is caught on the read path. + #[test] + fn load_state_rejects_account_type_column_mismatch() { + let conn = migrated_conn(); + let w = [0x11u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + + // Build a valid blob for PlatformPayment (account_index = 0). + let entry = AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 0, + key_class: 0, + }, + account_xpub: test_xpub(), + }; + let blob = blob::encode(&entry).unwrap(); + + // Insert with a deliberately wrong `account_type` column label so + // the typed column and the blob disagree. + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'identity_registration', 0, ?2)", + rusqlite::params![&w[..], blob], + ) + .unwrap(); + + let err = load_state(&conn, &w, &LoadCtx::strict()) + .expect_err("load_state must fail on type mismatch"); + assert!( + matches!(err, WalletStorageError::AccountRegistrationEntryMismatch), + "expected AccountRegistrationEntryMismatch, got {err:?}" + ); + } + + /// `load_state` must return `AccountRegistrationEntryMismatch` when the + /// typed `account_index` column disagrees with the decoded blob, even when + /// `account_type` matches. + #[test] + fn load_state_rejects_account_index_column_mismatch() { + let conn = migrated_conn(); + let w = [0x22u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + + // Blob encodes PlatformPayment at account index 0. + let entry = AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 0, + key_class: 0, + }, + account_xpub: test_xpub(), + }; + let blob = blob::encode(&entry).unwrap(); + + // Column says account_index = 1 but blob says 0 — deliberate mismatch. + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'platform_payment', 1, ?2)", + rusqlite::params![&w[..], blob], + ) + .unwrap(); + + let err = load_state(&conn, &w, &LoadCtx::strict()) + .expect_err("load_state must fail on index mismatch"); + assert!( + matches!(err, WalletStorageError::AccountRegistrationEntryMismatch), + "expected AccountRegistrationEntryMismatch, got {err:?}" + ); + } + + /// The `platform_payment` readers (`all_platform_payment_registrations`, + /// the production `load()` oracle via `platform_addrs::load_all`, and its + /// per-wallet sibling `list_platform_payment_registrations`) must reject a + /// row whose typed `key_class` column disagrees with the blob's + /// `PlatformPayment.key_class` — the exact discriminator the widened PK + /// exists to protect — mirroring `load_state`'s full cross-check. + #[test] + fn platform_payment_readers_reject_key_class_column_mismatch() { + let conn = migrated_conn(); + let w = [0x77u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + // Blob encodes key_class = 0 ... + let entry = AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 0, + key_class: 0, + }, + account_xpub: test_xpub(), + }; + let blob = blob::encode(&entry).unwrap(); + // ... but the typed key_class column says 1 — a mismatch on the very + // column that keeps distinct key classes from colliding. + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, key_class, account_xpub_bytes) \ + VALUES (?1, 'platform_payment', 0, 1, ?2)", + rusqlite::params![&w[..], blob], + ) + .unwrap(); + + // The bulk reader refuses the row per WALLET: the scan survives, and + // the wallet that owns the bad row carries the refusal. + let all = all_platform_payment_registrations(&conn) + .expect("the scan itself must survive one bad row"); + let err = all + .get(&w) + .expect("the wallet must be present in the scan") + .as_ref() + .expect_err("bulk reader must reject key_class mismatch"); + assert!( + matches!(err, WalletStorageError::AccountRegistrationEntryMismatch), + "bulk reader: expected AccountRegistrationEntryMismatch, got {err:?}" + ); + let err = list_platform_payment_registrations(&conn, &w) + .expect_err("per-wallet reader must reject key_class mismatch"); + assert!( + matches!(err, WalletStorageError::AccountRegistrationEntryMismatch), + "per-wallet reader: expected AccountRegistrationEntryMismatch, got {err:?}" + ); + } + + /// Baseline: a consistent row (column and blob agree) round-trips cleanly. + #[test] + fn load_state_accepts_consistent_row() { + let conn = migrated_conn(); + let w = [0x33u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + let entry = AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 3, + key_class: 0, + }, + account_xpub: test_xpub(), + }; + let blob = blob::encode(&entry).unwrap(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'platform_payment', 3, ?2)", + rusqlite::params![&w[..], blob], + ) + .unwrap(); + + let loaded = load_state(&conn, &w, &LoadCtx::strict()) + .expect("consistent row must load cleanly") + .ecdsa; + assert_eq!(loaded.len(), 1); + assert!(matches!( + loaded[0].account_type, + key_wallet::account::AccountType::PlatformPayment { account: 3, .. } + )); + } + + /// Two `PlatformPayment` accounts sharing `(account_type, account_index)` + /// but differing in `key_class` must both survive a persist — the widened + /// PK keeps distinct key classes from collapsing onto one row (the + /// data-loss bug this fix addresses). + #[test] + fn distinct_key_class_accounts_do_not_collide() { + let mut conn = migrated_conn(); + let w = [0x44u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + let entry = |key_class: u32| AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 0, + key_class, + }, + account_xpub: test_xpub(), + }; + { + let tx = conn.transaction().unwrap(); + apply_registrations(&tx, &w, &[entry(0), entry(1)]).unwrap(); + tx.commit().unwrap(); + } + let loaded = load_state(&conn, &w, &LoadCtx::strict()) + .expect("both key classes load") + .ecdsa; + assert_eq!(loaded.len(), 2, "distinct key classes must both persist"); + let key_classes: HashSet = loaded + .iter() + .map(|e| match e.account_type { + key_wallet::account::AccountType::PlatformPayment { key_class, .. } => key_class, + _ => unreachable!("only PlatformPayment was inserted"), + }) + .collect(); + assert_eq!(key_classes, HashSet::from([0, 1])); + } + + /// Two `DashpayReceivingFunds` accounts at the same `index` but for + /// different contacts (distinct `friend_identity_id`) must both survive — + /// the per-contact identity pair is the real account key and must not + /// collapse on the shared `(account_type, account_index)`. + #[test] + fn distinct_dashpay_friends_do_not_collide() { + let mut conn = migrated_conn(); + let w = [0x55u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + let entry = |friend: [u8; 32]| AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [0xAB; 32], + friend_identity_id: friend, + }, + account_xpub: test_xpub(), + }; + { + let tx = conn.transaction().unwrap(); + apply_registrations(&tx, &w, &[entry([0x01; 32]), entry([0x02; 32])]).unwrap(); + tx.commit().unwrap(); + } + let loaded = load_state(&conn, &w, &LoadCtx::strict()) + .expect("both contacts load") + .ecdsa; + assert_eq!(loaded.len(), 2, "distinct contacts must both persist"); + let friends: HashSet<[u8; 32]> = loaded + .iter() + .map(|e| match e.account_type { + key_wallet::account::AccountType::DashpayReceivingFunds { + friend_identity_id, + .. + } => friend_identity_id, + _ => unreachable!("only DashpayReceivingFunds was inserted"), + }) + .collect(); + assert_eq!(friends, HashSet::from([[0x01; 32], [0x02; 32]])); + } + + /// Re-persisting the same account (identical full `AccountType`) updates in + /// place rather than inserting a duplicate — the idempotent upsert the + /// widened PK must preserve. + #[test] + fn idempotent_repersist_does_not_duplicate() { + let mut conn = migrated_conn(); + let w = [0x66u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + let entry = AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 2, + key_class: 1, + }, + account_xpub: test_xpub(), + }; + for _ in 0..2 { + let tx = conn.transaction().unwrap(); + apply_registrations(&tx, &w, std::slice::from_ref(&entry)).unwrap(); + tx.commit().unwrap(); + } + let loaded = load_state(&conn, &w, &LoadCtx::strict()) + .expect("load") + .ecdsa; + assert_eq!(loaded.len(), 1, "re-persist must not duplicate the row"); + } + + /// Every [`key_wallet::account::AccountType`] variant; the wildcard-free + /// match below fails to compile if upstream adds one. `Standard` appears + /// twice — once per `StandardAccountType` — because both map to distinct + /// labels. fn all_account_type_variants() -> Vec { use key_wallet::account::{AccountType, StandardAccountType}; let variants = vec![ @@ -283,6 +1137,10 @@ mod tests { index: 0, standard_account_type: StandardAccountType::BIP44Account, }, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP32Account, + }, AccountType::CoinJoin { index: 0 }, AccountType::IdentityRegistration, AccountType::IdentityTopUp { @@ -311,9 +1169,6 @@ mod tests { key_class: 0, }, ]; - // Compile-time exhaustiveness gate: an added upstream variant - // makes this match fail to compile and forces the sample list - // (and `account_type_db_label`) to be updated. for v in &variants { match v { AccountType::Standard { .. } @@ -336,23 +1191,81 @@ mod tests { variants } - fn all_pool_type_variants() -> Vec { - use key_wallet::managed_account::address_pool::AddressPoolType; - let variants = vec![ - AddressPoolType::External, - AddressPoolType::Internal, - AddressPoolType::Absent, - AddressPoolType::AbsentHardened, - ]; - for v in &variants { - match v { - AddressPoolType::External - | AddressPoolType::Internal - | AddressPoolType::Absent - | AddressPoolType::AbsentHardened => {} - } + /// No two account types may share a full PK tuple, whatever axes they + /// carry. This is the runtime half of the wildcard-free mappers: those + /// make an untaught variant a compile error, and this makes a variant + /// that IS taught but mapped onto an existing key a test failure. + /// + /// Reached through `all_account_type_variants`, whose own exhaustive + /// match means a new upstream variant cannot arrive without a decision + /// being taken here. + #[test] + fn no_two_account_types_share_a_pk_tuple() { + let keys: Vec<_> = all_account_type_variants() + .into_iter() + .map(|at| { + ( + account_type_db_label(&at), + account_index(&at), + account_key_class(&at), + account_dashpay_ids(&at), + ) + }) + .collect(); + let mut seen: HashSet<_> = HashSet::new(); + let collisions: Vec<_> = keys.iter().filter(|key| !seen.insert(*key)).collect(); + assert!( + collisions.is_empty(), + "these primary keys are claimed by more than one account type, so \ + the second account written would overwrite the first: {collisions:?}" + ); + } + + /// The reader's SQL inlines these two labels (SQLite has no list + /// binding), so a rename upstream must break here rather than silently + /// route every provider row into the ECDSA decode path. + #[test] + fn provider_key_account_labels_match_sql_literals() { + use key_wallet::account::AccountType; + assert_eq!( + account_type_db_label(&AccountType::ProviderOperatorKeys), + "provider_operator" + ); + assert_eq!( + account_type_db_label(&AccountType::ProviderPlatformKeys), + "provider_platform" + ); + } + + /// All four provider account types collapse to the same index / + /// key-class / DashPay sentinels, so `account_type` is the only thing + /// keeping them off each other's PK. Two that collided would silently + /// overwrite on upsert. + #[test] + fn provider_variants_have_distinct_pk_tuples() { + use key_wallet::account::AccountType; + let key = |at: AccountType| { + ( + account_type_db_label(&at), + account_index(&at), + account_key_class(&at), + account_dashpay_ids(&at), + ) + }; + let keys: Vec<_> = [ + AccountType::ProviderVotingKeys, + AccountType::ProviderOwnerKeys, + AccountType::ProviderOperatorKeys, + AccountType::ProviderPlatformKeys, + ] + .into_iter() + .map(key) + .collect(); + for k in &keys { + assert_eq!((k.1, k.2, k.3), (0, 0, ([0u8; 32], [0u8; 32]))); } - variants + let distinct: HashSet<_> = keys.iter().collect(); + assert_eq!(distinct.len(), 4, "provider PK tuples must not collide"); } #[test] @@ -369,17 +1282,85 @@ mod tests { ); } + /// Pins the live domain to the list frozen in the latest migration that + /// rebuilt `account_registrations` (`V008__rehydration_base_schema.rs`). + /// That frozen list is this array plus [`LEGACY_STANDARD_LABEL`], which no + /// writer emits but pre-split rows still carry. + /// + /// IF THIS FAILS: do NOT edit V008's list to match. Refinery checksums a + /// migration's rendered SQL, so changing an applied migration's body makes + /// every database that already ran it fail to open, permanently. Append a + /// migration rebuilding the table with the widened CHECK (the + /// `V004__asset_lock_recovered_status.rs` pattern), then update this pin. #[test] - fn pool_type_labels_match_enum() { - let from_writer: HashSet<&'static str> = all_pool_type_variants() - .iter() - .map(pool_type_db_label) - .collect(); - let from_const: HashSet<&'static str> = POOL_TYPE_LABELS.iter().copied().collect(); + fn account_type_labels_frozen_in_v007() { assert_eq!( - from_writer, from_const, - "POOL_TYPE_LABELS ({:?}) drifted from pool_type_db_label codomain ({:?})", - from_const, from_writer + ACCOUNT_TYPE_LABELS, + &[ + "standard_bip44", + "standard_bip32", + "coinjoin", + "identity_registration", + "identity_topup", + "identity_topup_unbound", + "identity_invitation", + "asset_lock_address_topup", + "asset_lock_shielded_topup", + "provider_voting", + "provider_owner", + "provider_operator", + "provider_platform", + "dashpay_receiving", + "dashpay_external", + "platform_payment", + ] + ); + } + + /// The pre-split `standard` label matches EITHER standard variant, so a + /// database written before the domain split still cross-checks clean. A + /// migration cannot resolve which variant such a row is -- the answer is in + /// the blob, not in SQL -- so rewriting the label would be a guess, and a + /// wrong guess makes a row that loads today fail under `LoadPolicy::Strict`. + #[test] + fn legacy_standard_label_matches_either_standard_variant() { + use key_wallet::account::{AccountType, StandardAccountType}; + for standard_account_type in [ + StandardAccountType::BIP44Account, + StandardAccountType::BIP32Account, + ] { + let entry_type = AccountType::Standard { + index: 0, + standard_account_type, + }; + assert!( + db_label_matches_entry(LEGACY_STANDARD_LABEL, &entry_type), + "legacy `standard` must match {standard_account_type:?}" + ); + assert!( + db_label_matches_entry(account_type_db_label(&entry_type), &entry_type), + "the split label must still match its own variant" + ); + } + } + + /// The legacy equivalence is narrow: it admits `standard` for a Standard + /// account and nothing else. It must not let one split label stand in for + /// the other, nor `standard` stand in for a non-standard account. + #[test] + fn legacy_standard_label_equivalence_is_narrow() { + use key_wallet::account::{AccountType, StandardAccountType}; + let bip44 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + assert!( + !db_label_matches_entry("standard_bip32", &bip44), + "one split label must never stand in for the other" + ); + assert!( + !db_label_matches_entry(LEGACY_STANDARD_LABEL, &AccountType::IdentityRegistration), + "legacy `standard` must not match a non-standard 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 index 071674ecfb1..f437755606a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -1,26 +1,122 @@ //! `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 +//! SQL queries, plus a bincode-serde encoded [`AssetLockEntryWire`] in the //! `lifecycle_blob` column. +//! +//! `AssetLockEntry.proof`'s `#[serde(tag = "$type")]` enum is rejected by +//! bincode-serde (needs `deserialize_any`), so [`AssetLockEntryWire`] +//! pre-encodes the proof with bincode's native `Encode`/`Decode` and rides +//! the surrounding fields on the serde encoder — mirroring `IdentityKeyWire` +//! in `identity_keys.rs`. use rusqlite::{params, Transaction}; +use serde::{Deserialize, Serialize}; +use dpp::prelude::AssetLockProof; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use platform_wallet::changeset::AssetLockChangeSet; use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob; -// Imports used only by the test-gated readers below. -#[cfg(any(test, feature = "__test-helpers"))] use { dashcore::OutPoint, platform_wallet::changeset::AssetLockEntry, platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock, rusqlite::Connection, std::collections::BTreeMap, }; +use crate::sqlite::schema::blob::impl_persistable_blob; + +/// On-disk wire shape for [`AssetLockEntry`]. `proof` rides as a natively +/// bincode-encoded `Option>` rather than routing the internally-tagged +/// [`AssetLockProof`] enum through the `bincode::serde` bridge — that bridge +/// cannot service the `deserialize_any` an internally-tagged serde enum needs, +/// which made a `proof: Some(..)` row write-once/read-never (issue #4133). +/// +/// Fields 1–7 preserve the exact order and serde encodings of `AssetLockEntry` +/// (including the shared `asset_lock_funding_type` adapter) so a pre-fix +/// `proof: None` row decodes byte-identically under this type — no migration +/// needed for those rows. +#[derive(Serialize, Deserialize)] +struct AssetLockEntryWire { + out_point: OutPoint, + transaction: dashcore::Transaction, + account_index: u32, + #[serde(with = "platform_wallet::changeset::serde_adapters::asset_lock_funding_type")] + funding_type: AssetLockFundingType, + identity_index: u32, + amount_duffs: u64, + status: AssetLockStatus, + /// Natively bincode-encoded [`AssetLockProof`]; see the type docs. + proof: Option>, +} + +// PUBLIC material only: asset-lock lifecycle reaching `lifecycle_blob`. +impl_persistable_blob!(AssetLockEntryWire); + +/// Encode an [`AssetLockEntry`] to its on-disk `lifecycle_blob` form via the +/// wire type. Test-only seam so integration tests can forge a lifecycle blob +/// without naming the crate-internal [`AssetLockEntryWire`]. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn encode_entry_for_test(entry: &AssetLockEntry) -> Result, WalletStorageError> { + blob::encode(&AssetLockEntryWire::from_entry(entry)?) +} + +impl AssetLockEntryWire { + /// Project an [`AssetLockEntry`] onto its wire shape, pre-encoding the + /// proof natively (see the type docs). + fn from_entry(entry: &AssetLockEntry) -> Result { + let proof = entry + .proof + .as_ref() + .map(|p| bincode::encode_to_vec(p, blob::bounded_config())) + .transpose()?; + Ok(Self { + out_point: entry.out_point, + transaction: entry.transaction.clone(), + account_index: entry.account_index, + funding_type: entry.funding_type, + identity_index: entry.identity_index, + amount_duffs: entry.amount_duffs, + status: entry.status.clone(), + proof, + }) + } + + /// Reconstruct the [`AssetLockEntry`], natively decoding the proof. + /// Rejects trailing bytes past the typed proof length, mirroring + /// `IdentityKeyWire::into_entry` and the outer `blob::decode` guard. + fn into_entry(self) -> Result { + let proof = match self.proof { + Some(bytes) => { + let (proof, consumed): (AssetLockProof, usize) = + bincode::decode_from_slice(&bytes, blob::bounded_config())?; + if consumed != bytes.len() { + return Err(WalletStorageError::blob_decode( + "unexpected trailing bytes in asset_locks proof bincode", + )); + } + Some(proof) + } + None => None, + }; + Ok(AssetLockEntry { + out_point: self.out_point, + transaction: self.transaction, + account_index: self.account_index, + funding_type: self.funding_type, + identity_index: self.identity_index, + amount_duffs: self.amount_duffs, + status: self.status, + proof, + }) + } +} + pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -55,7 +151,7 @@ pub fn apply( )?; for (op, entry) in &cs.asset_locks { let op_bytes = blob::encode_outpoint(op)?; - let lifecycle_blob = blob::encode(entry)?; + let lifecycle_blob = blob::encode(&AssetLockEntryWire::from_entry(entry)?)?; stmt.execute(params![ wallet_id.as_slice(), &op_bytes[..], @@ -122,7 +218,7 @@ pub fn apply( /// codomain ([`status_str`]); /// - `asset_lock_status_labels_frozen_in_latest_migration` — this array /// ⇔ the latest migration's frozen list, so ADDING a variant fails -/// with instructions to append a new table-rebuild migration (V005+) +/// with instructions to append a new table-rebuild migration (V018+) /// instead of editing a shipped one. #[cfg(test)] pub(crate) const ASSET_LOCK_STATUS_LABELS: &[&str] = &[ @@ -147,35 +243,29 @@ fn status_str(s: &AssetLockStatus) -> &'static str { /// Per-wallet asset-lock slice as returned by the readers — outer-keyed /// by `account_index`, inner-keyed by outpoint. -#[cfg(any(test, feature = "__test-helpers"))] pub type AssetLocksByAccount = BTreeMap>; -/// Decode one raw `(outpoint_bytes, account_index, lifecycle_blob)` +/// Decode one raw `(outpoint_bytes, account_index, lifecycle_blob, status)` /// tuple into the typed `(account_index, OutPoint, TrackedAssetLock)` -/// triple that [`load_state`] consumes. +/// triple that the reader functions consume. /// -/// 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. -#[cfg(any(test, feature = "__test-helpers"))] +/// Malformed fields and entry mismatches are always fatal. Status drift is +/// policy-controlled: Recovery uses the blob status with consumption sticky. fn decode_row( op_bytes: &[u8], account_index: i64, blob_bytes: &[u8], + typed_status: &str, + ctx: &LoadCtx, ) -> Result<(u32, OutPoint, TrackedAssetLock), WalletStorageError> { let outpoint = blob::decode_outpoint(op_bytes)?; - let entry: AssetLockEntry = blob::decode(blob_bytes)?; + let wire: AssetLockEntryWire = blob::decode(blob_bytes)?; + let mut entry = wire.into_entry()?; 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, - })?; - // 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 + crate::sqlite::util::safe_cast::i64_to_u32("asset_locks.account_index", account_index)?; + // Typed-column vs blob cross-check: corruption that passes PRAGMA + // integrity_check would otherwise mis-bucket the lock or report a + // different outpoint / account index than the indexed columns it was // selected by. if entry.out_point != outpoint || entry.account_index != account_index { return Err(WalletStorageError::AssetLockEntryMismatch { @@ -185,6 +275,24 @@ fn decode_row( blob_account_index: entry.account_index, }); } + let blob_status = status_str(&entry.status); + if blob_status != typed_status { + let consumed = typed_status == "consumed" || entry.status == AssetLockStatus::Consumed; + ctx.tolerate( + LoadSite::AssetLockStatusDrift, + WalletStorageError::AssetLockStatusMismatch { + outpoint: outpoint.to_string(), + typed_status: typed_status.to_owned(), + blob_status: blob_status.to_owned(), + }, + )?; + // `load_unconsumed` pre-filters typed `consumed` rows, so the blob can + // only withdraw a lock there. Sticky consumption also keeps the + // unfiltered test/inspection reader conservative. + if consumed { + entry.status = AssetLockStatus::Consumed; + } + } let tracked = TrackedAssetLock { out_point: entry.out_point, transaction: entry.transaction, @@ -198,47 +306,335 @@ fn decode_row( 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. Retained for this crate's integration tests until -/// the rehydration path consumes it in `load()`. +/// Full-history asset-lock slice bucketed by account index, **including** +/// terminal `Consumed` rows (inspection reader for this crate's tests). Use +/// [`load_unconsumed`] for the rehydration feed. Status drift is fatal under +/// Strict; Recovery uses the blob status with consumption kept sticky. #[cfg(any(test, feature = "__test-helpers"))] pub fn load_state( conn: &Connection, wallet_id: &WalletId, + ctx: &LoadCtx, ) -> Result { let mut stmt = conn.prepare( - "SELECT outpoint, account_index, lifecycle_blob \ + "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ 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 rows = stmt.query(params![wallet_id.as_slice()])?; 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)?; + while let Some(row) = rows.next()? { + blob::check_size(row.get::<_, i64>(0)?)?; + let op_bytes: Vec = row.get(1)?; + let account_index: i64 = row.get(2)?; + blob::check_size(row.get::<_, i64>(3)?)?; + let blob_bytes: Vec = row.get(4)?; + let status: String = row.get(5)?; + let (acct, outpoint, tracked) = + decode_row(&op_bytes, account_index, &blob_bytes, &status, ctx)?; out.entry(acct).or_default().insert(outpoint, tracked); } Ok(out) } +/// Status-filtered rehydration feed: every asset lock **except** terminal +/// `Consumed` rows, bucketed by account index. Feeding `Consumed` locks back +/// into the live set would resurrect a spent one-shot lock as actionable +/// (A04/A08), so the exclusion is at the SQL level (`status NOT IN +/// ('consumed')`, `status` indexed); history stays visible via [`load_state`]. +/// Malformed rows remain fatal; Recovery tolerates status drift and excludes +/// the row if either representation says it is consumed. +pub fn load_unconsumed( + conn: &Connection, + wallet_id: &WalletId, + ctx: &LoadCtx, +) -> Result { + let mut stmt = conn.prepare( + "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ + FROM asset_locks WHERE wallet_id = ?1 AND status NOT IN ('consumed')", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut out: AssetLocksByAccount = BTreeMap::new(); + while let Some(row) = rows.next()? { + blob::check_size(row.get::<_, i64>(0)?)?; + let op_bytes: Vec = row.get(1)?; + let account_index: i64 = row.get(2)?; + blob::check_size(row.get::<_, i64>(3)?)?; + let blob_bytes: Vec = row.get(4)?; + let status: String = row.get(5)?; + let (acct, outpoint, tracked) = + decode_row(&op_bytes, account_index, &blob_bytes, &status, ctx)?; + if tracked.status == AssetLockStatus::Consumed { + continue; + } + out.entry(acct).or_default().insert(outpoint, tracked); + } + Ok(out) +} + +/// Every asset lock bucketed by account index, **including** terminal +/// `Consumed` — history/inspection only; use [`load_unconsumed`] for the +/// rehydration feed. A row that fails to decode is a hard +/// [`WalletStorageError`]. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn list_active( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + load_state(conn, wallet_id, &LoadCtx::strict()) +} + #[cfg(test)] mod tests { use super::*; use std::collections::HashSet; - /// Exhaustive sample of every [`AssetLockStatus`] variant. The - /// trailing match arm in the loop fails to compile if upstream - /// adds a variant — forcing the developer to extend the list, - /// `status_str`, and [`ASSET_LOCK_STATUS_LABELS`] together. + /// Open an in-memory connection with the full schema applied. + fn migrated_conn() -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + use dashcore::hashes::Hash; + + fn sample_outpoint(b: u8) -> OutPoint { + OutPoint { + txid: dashcore::Txid::from_byte_array([b; 32]), + vout: 0, + } + } + + fn sample_transaction() -> dashcore::Transaction { + dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + } + } + + /// An `AssetLockEntry` at the given status carrying `proof`. + fn entry_with_proof( + op: OutPoint, + status: AssetLockStatus, + proof: Option, + ) -> AssetLockEntry { + AssetLockEntry { + out_point: op, + transaction: sample_transaction(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount_duffs: 1000, + status, + proof, + } + } + + /// Store `entry` through the real `apply` writer, then read it back + /// with `load_state`. Returns the reconstructed entry so a test can + /// pin a full DB round-trip including the proof. + fn roundtrip_through_db(entry: &AssetLockEntry) -> AssetLockEntry { + let mut conn = migrated_conn(); + let w = [0xAAu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks.insert(entry.out_point, entry.clone()); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let mut locks = load_state(&conn, &w, &LoadCtx::strict()).unwrap(); + locks + .remove(&entry.account_index) + .and_then(|mut by_op| by_op.remove(&entry.out_point)) + .map(|tracked| AssetLockEntry { + out_point: tracked.out_point, + transaction: tracked.transaction, + account_index: tracked.account_index, + funding_type: tracked.funding_type, + identity_index: tracked.identity_index, + amount_duffs: tracked.amount, + status: tracked.status, + proof: tracked.proof, + }) + .expect("stored asset lock must load back") + } + + /// Repro for #4133: a `proof: Some(AssetLockProof::Chain(..))` row must + /// survive a full `apply` → `load_state` round-trip. Before the wire-type + /// fix this failed at decode with `BincodeDecode`/`AnyNotSupported`, because + /// the internally-tagged proof enum was routed through the `bincode::serde` + /// bridge — write-once, read-never. + #[test] + fn wire_round_trips_chain_proof() { + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + let op = sample_outpoint(0x31); + let proof = AssetLockProof::Chain(ChainAssetLockProof::new(42, [0x07u8; 36])); + let entry = entry_with_proof(op, AssetLockStatus::ChainLocked, Some(proof)); + let restored = roundtrip_through_db(&entry); + assert_eq!(restored, entry, "Chain proof must survive the round-trip"); + } + + /// Repro for #4133 (Instant variant): a `proof: + /// Some(AssetLockProof::Instant(..))` row must also survive the round-trip. + #[test] + fn wire_round_trips_instant_proof() { + use dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof; + let op = sample_outpoint(0x32); + // Distinct, non-default field values so the round-trip proves field + // fidelity, not merely that `default() == default()`. + let inner = { + let mut p = InstantAssetLockProof::default(); + p.transaction.version = 3; + p.transaction.lock_time = 111; + p.output_index = 2; + // `default()` leaves the nested `InstantLock.inputs` empty; a real + // IS-lock always carries at least one input, so populate it to + // exercise the length-prefixed-vec encoding path a genuine proof + // uses. + p.instant_lock.inputs = vec![sample_outpoint(0xA1), sample_outpoint(0xA2)]; + p + }; + let proof = AssetLockProof::Instant(inner); + let entry = entry_with_proof(op, AssetLockStatus::InstantSendLocked, Some(proof)); + let restored = roundtrip_through_db(&entry); + assert_eq!(restored, entry, "Instant proof must survive the round-trip"); + } + + /// The wire type rejects a `proof` payload whose `AssetLockProof` prefix is + /// valid but carries trailing garbage, rather than silently dropping the + /// tail — mirrors `into_entry_rejects_trailing_bytes_in_public_key_bincode`. + #[test] + fn into_entry_rejects_trailing_bytes_in_proof_bincode() { + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + let proof = AssetLockProof::Chain(ChainAssetLockProof::new(7, [0x01u8; 36])); + let mut proof_bincode = bincode::encode_to_vec(&proof, blob::bounded_config()).unwrap(); + proof_bincode.push(0xFF); // trailing garbage past the typed length + + let wire = AssetLockEntryWire { + out_point: sample_outpoint(0x33), + transaction: sample_transaction(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount_duffs: 1000, + status: AssetLockStatus::ChainLocked, + proof: Some(proof_bincode), + }; + let err = wire.into_entry().expect_err("trailing bytes must error"); + assert!( + matches!(err, WalletStorageError::BlobDecode { .. }), + "expected BlobDecode for trailing-byte garbage, got {err:?}" + ); + } + + /// Compat pin: a pre-fix `proof: None` row (serialized straight from + /// `AssetLockEntry`, the old on-disk shape) encodes byte-identically to the + /// new `AssetLockEntryWire` and decodes back unchanged. Guarantees the + /// common case rehydrates with no migration. + #[test] + fn none_proof_row_is_byte_identical_across_shapes() { + let entry = entry_with_proof(sample_outpoint(0x34), AssetLockStatus::Built, None); + + // Old on-disk bytes: exactly what the pre-fix `blob::encode` produced + // (`bincode::serde::encode_to_vec` over the `AssetLockEntry` itself). + let old_bytes = bincode::serde::encode_to_vec(&entry, blob::bounded_config()).unwrap(); + let new_bytes = blob::encode(&AssetLockEntryWire::from_entry(&entry).unwrap()).unwrap(); + assert_eq!( + old_bytes, new_bytes, + "a None-proof row must be byte-identical old vs new" + ); + + // And the old bytes still decode under the new wire type. + let wire: AssetLockEntryWire = blob::decode(&old_bytes).unwrap(); + assert_eq!( + wire.into_entry().unwrap(), + entry, + "pre-fix None row must decode under the new wire type" + ); + } + + /// Strict rejects status drift with a typed error; Recovery keeps + /// consumption sticky so a stale blob cannot resurrect a spent lock. + #[test] + fn load_state_rejects_status_column_mismatch() { + let mut conn = migrated_conn(); + let w = [0xAAu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + + // Build a minimal `AssetLockEntry` with `status = Built`. + let outpoint = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0x01u8; 32]), + vout: 0, + }; + let entry = AssetLockEntry { + out_point: outpoint, + // Dashcore Transaction with integer version and lock_time. + transaction: dashcore::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, + }; + let lifecycle_blob = + blob::encode(&AssetLockEntryWire::from_entry(&entry).unwrap()).unwrap(); + let op_bytes = blob::encode_outpoint(&outpoint).unwrap(); + + // Insert with status column = 'consumed' but blob says 'built'. + { + let tx = conn.transaction().unwrap(); + tx.execute( + "INSERT INTO asset_locks \ + (wallet_id, outpoint, status, account_index, identity_index, \ + amount_duffs, lifecycle_blob) \ + VALUES (?1, ?2, 'consumed', 0, 0, 1000, ?3)", + params![&w[..], &op_bytes[..], lifecycle_blob], + ) + .unwrap(); + tx.commit().unwrap(); + } + + // Strict rejects the disagreement with the purpose-built error. + let err = load_state(&conn, &w, &LoadCtx::strict()) + .expect_err("load_state must reject a status column vs blob mismatch"); + assert!( + matches!(err, WalletStorageError::AssetLockStatusMismatch { .. }), + "expected AssetLockStatusMismatch, got {err:?}" + ); + + // Recovery keeps consumption sticky even though the blob says Built. + let ctx = LoadCtx::recovery(); + let state = load_state(&conn, &w, &ctx).expect("recovery tolerates status drift"); + assert_eq!(state[&0][&outpoint].status, AssetLockStatus::Consumed); + assert_eq!( + ctx.degradation() + .by_site + .get(&LoadSite::AssetLockStatusDrift), + Some(&1) + ); + } + + /// Every [`AssetLockStatus`] variant; the wildcard-free match below fails + /// to compile if upstream adds one. fn all_asset_lock_status_variants() -> Vec { let variants = vec![ AssetLockStatus::Built, @@ -279,7 +675,7 @@ mod tests { /// asset-lock migration (`V004__asset_lock_recovered_status.rs`). /// Shipped migrations interpolate nothing — their generated SQL is /// checksummed by Refinery, so widening the domain means APPENDING - /// a new table-rebuild migration (V005+) with the new frozen list + /// a new table-rebuild migration (V018+) with the new frozen list /// and updating this pin, never editing V001/V004 in place. /// /// IF THIS FAILS: do NOT edit a shipped migration (its Refinery diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs index 502d984116d..c53f2584be2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs @@ -1,29 +1,50 @@ -//! BLOB-column codec helpers. +//! BLOB-column codec helpers: thin `bincode::serde` wrappers so every +//! `_blob` column uses one encoding path. Schema evolution is gated by the +//! refinery migration version — no per-blob revision tag. //! -//! 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`] encode a `dashcore::OutPoint` -//! the same way — via bincode-serde — for the `outpoint` PRIMARY KEY -//! columns (`core_utxos`, `asset_locks`). The bytes are a stable but not -//! fixed-length key; both columns are used for exact-match PK lookups, so -//! variable width is fine (no range scans or byte-order dependence). +//! [`encode_outpoint`] / [`decode_outpoint`] encode `dashcore::OutPoint` +//! the same way for the `outpoint` PK columns. The key is variable-width, +//! which is fine for the exact-match PK lookups (no range scans). use serde::de::DeserializeOwned; use serde::Serialize; use crate::sqlite::error::WalletStorageError; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::{params, Connection}; + +/// Sealed-trait machinery enforcing the no-key-material-in-DB invariant at +/// the type level: only types opting in via [`impl_persistable_blob!`] can +/// reach [`encode`]. +pub(crate) mod sealed { + /// `pub(crate)` supertrait of [`PersistableBlob`] — downstream cannot + /// name it, so the trait is sealed. + pub trait Sealed {} +} + +/// Marker for types allowed into a `_blob` column. Sealed via +/// [`sealed::Sealed`] so adding a (possibly key-bearing) type to the +/// persistence path is an explicit, reviewable `impl` rather than a silent +/// `T: Serialize` slip. +pub trait PersistableBlob: Serialize + sealed::Sealed {} + +/// Seal-and-mark a type for [`blob::encode`](encode). +macro_rules! impl_persistable_blob { + ($($t:ty),+ $(,)?) => { + $( + impl $crate::sqlite::schema::blob::sealed::Sealed for $t {} + impl $crate::sqlite::schema::blob::PersistableBlob for $t {} + )+ + }; +} +pub(crate) use impl_persistable_blob; -/// 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; +/// Hard cap on bincode-serde allocations, applied symmetrically to encode + +/// decode so a crafted length prefix can't OOM the host. Shares the crate-root +/// [`SIZE_LIMIT_BYTES`](crate::SIZE_LIMIT_BYTES) with the KV value cap. +pub const BLOB_SIZE_LIMIT_BYTES: usize = crate::SIZE_LIMIT_BYTES; -fn bounded_config() -> bincode::config::Configuration< +pub(crate) fn bounded_config() -> bincode::config::Configuration< bincode::config::LittleEndian, bincode::config::Varint, bincode::config::Limit, @@ -31,8 +52,62 @@ fn bounded_config() -> bincode::config::Configuration< bincode::config::standard().with_limit::() } -/// Encode a serde-derived value into a `BLOB` payload. -pub fn encode(value: &T) -> Result, WalletStorageError> { +/// Gate a variable-width blob column BEFORE materializing the `Vec`. +/// `len` is the value of `length()` selected in the same row. +/// Returns [`WalletStorageError::BlobTooLarge`] when `len` exceeds the cap. +pub(crate) fn check_size(len: i64) -> Result<(), WalletStorageError> { + let len_usize = usize::try_from(len).unwrap_or(usize::MAX); + if len_usize > BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len_usize, + limit_bytes: BLOB_SIZE_LIMIT_BYTES, + }); + } + Ok(()) +} + +/// Gate the largest value selected by a one-column aggregate query. +pub(crate) fn check_max_column_len( + conn: &Connection, + sql: &'static str, + wallet_id: &WalletId, +) -> Result<(), WalletStorageError> { + let max_len: Option = + conn.query_row(sql, params![wallet_id.as_slice()], |row| row.get(0))?; + if let Some(len) = max_len { + check_size(len)?; + } + Ok(()) +} + +/// Decode a stored script into an address for `network`. +pub(crate) fn decode_script_to_address( + raw: impl Into>, + network: dashcore::Network, +) -> Result { + let script = dashcore::ScriptBuf::from_bytes(raw.into()); + Ok(dashcore::Address::from_script(&script, network)?) +} + +/// Gate a fixed-width blob column BEFORE materializing the `Vec`. +/// Oversize (`len` past the cap) surfaces as [`WalletStorageError::BlobTooLarge`]; +/// any other deviation from `expected` as [`WalletStorageError::BlobDecode`]. +pub(crate) fn check_fixed_width( + len: i64, + expected: usize, + col: &'static str, +) -> Result<(), WalletStorageError> { + check_size(len)?; + if usize::try_from(len).unwrap_or(usize::MAX) != expected { + return Err(WalletStorageError::blob_decode(col)); + } + Ok(()) +} + +/// Encode a [`PersistableBlob`] value into a `BLOB` payload. The sealed bound +/// (not a bare `T: Serialize`) guards against unreviewed types reaching a +/// `_blob` column. +pub fn encode(value: &T) -> Result, WalletStorageError> { Ok(bincode::serde::encode_to_vec(value, bounded_config())?) } @@ -66,9 +141,11 @@ pub fn decode(blob: &[u8]) -> Result Ok(value) } -/// Encode a `dashcore::OutPoint` for an `outpoint` PRIMARY KEY column. -/// Uses the same bincode-serde path as every other column — a stable -/// (not fixed-length) key, which the exact-match PK lookups don't mind. +// An outpoint is a PUBLIC (txid, vout) reference — never key material. +impl_persistable_blob!(dashcore::OutPoint); + +/// Encode a `dashcore::OutPoint` for an `outpoint` PRIMARY KEY column via the +/// shared [`encode`] path. pub fn encode_outpoint(op: &dashcore::OutPoint) -> Result, WalletStorageError> { encode(op) } @@ -76,7 +153,6 @@ pub fn encode_outpoint(op: &dashcore::OutPoint) -> Result, WalletStorage /// Decode an outpoint key produced by [`encode_outpoint`]. Rejects /// malformed or trailing bytes with a typed [`WalletStorageError`] via /// the shared [`decode`] path. -#[cfg(any(test, feature = "__test-helpers"))] pub fn decode_outpoint(bytes: &[u8]) -> Result { decode(bytes) } @@ -90,6 +166,7 @@ mod tests { a: u32, b: String, } + impl_persistable_blob!(Dummy); #[test] fn encode_decode_roundtrip() { @@ -145,6 +222,12 @@ mod tests { vout: 9, }; let bytes = encode_outpoint(&op).unwrap(); + assert_eq!(bytes[0], 32, "bincode prefixes the txid byte-array length"); + assert_eq!( + &bytes[1..33], + AsRef::<[u8]>::as_ref(&op.txid), + "the txid must occupy SQLite substr bytes 2 through 33" + ); assert_eq!(decode_outpoint(&bytes).unwrap(), op); } @@ -161,11 +244,9 @@ mod tests { assert_eq!(decode_outpoint(&bytes).unwrap(), op); } - /// A truncated / malformed outpoint key is a typed decode error, not - /// a panic — replaces the old fixed-36-byte length check. A 4-byte - /// input is too short for the 32-byte txid prefix, so bincode fails - /// deterministically with `BincodeDecode` (UnexpectedEnd) before the - /// trailing-bytes check. + /// A truncated outpoint key is a typed decode error, not a panic: a + /// 4-byte input is too short for the 32-byte txid prefix, so bincode + /// fails deterministically with `BincodeDecode` (UnexpectedEnd). #[test] fn decode_outpoint_rejects_malformed_bytes() { let res = decode_outpoint(&[0x01u8; 4]); @@ -174,4 +255,21 @@ mod tests { "a 4-byte payload must fail as BincodeDecode, got {res:?}" ); } + + /// Pins the encoded layout `V014__single_source_core_confirmation_height` + /// depends on: one length-prefix byte, then the 32 txid bytes. That + /// migration lifts the txid with `substr(outpoint, 2, 32)`, so a change + /// in the encoding must fail here rather than backfill the wrong bytes. + #[test] + fn encode_outpoint_txid_occupies_bytes_two_to_thirty_three() { + use dashcore::hashes::Hash; + let txid_bytes = [0x5Au8; 32]; + let op = dashcore::OutPoint::new(dashcore::Txid::from_byte_array(txid_bytes), 3); + let encoded = encode_outpoint(&op).unwrap(); + assert_eq!( + &encoded[1..33], + &txid_bytes, + "SQL substr(outpoint, 2, 32) must select exactly the txid" + ); + } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs index e4ba065bf3c..c7d482995d7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -14,35 +14,32 @@ use platform_wallet::changeset::ContactChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob; -// `any(test, …)`, not `__test-helpers`-only: `load_ignored_senders` (and its -// `decode_pair_key` helper) are gated the same way — they're reached by -// `identities::load_state`, whose gate includes plain `test`. -#[cfg(any(test, 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::{ContactRequest, EstablishedContact}; -#[cfg(any(test, feature = "__test-helpers"))] use rusqlite::Connection; -#[cfg(any(test, feature = "__test-helpers"))] use std::collections::BTreeMap; +// PUBLIC material only: contact-request types + the accepted-account index (a +// list of account indices). Contact requests carry public keys/refs. +crate::sqlite::schema::blob::impl_persistable_blob!(ContactRequest, Vec); + /// Single source of truth for the `contacts.state` TEXT-column domain. /// /// One label per lifecycle stage of a DashPay contact relationship -/// (writer side: [`contact_state_db_label`]). The migration in -/// `migrations/V001__initial.rs` interpolates this array into the -/// `CHECK (state IN (...))` clause so an unknown label is rejected at -/// insert time rather than landing as silent garbage. The -/// `contact_state_labels_match_enum` unit test below enforces -/// set-equality between this array and the writer's output — drift (a -/// renamed/added stage) becomes a failing test, not a runtime -/// divergence between Rust and SQLite. +/// (writer side: [`contact_state_db_label`]). The migrations interpolate +/// nothing: V001 freezes its own copy of this domain into a +/// `CHECK (state IN (...))` clause, because a generated-SQL change breaks +/// that migration's Refinery checksum on every database that already applied +/// it. `contact_state_labels_match_enum` enforces set-equality between this +/// array and the writer's output; `contact_state_labels_frozen_in_v001` pins +/// it to V001's frozen list. +#[cfg(test)] pub(crate) const CONTACT_STATE_LABELS: &[&str] = &["sent", "received", "established"]; /// Lifecycle stage of a `contacts` row. @@ -70,7 +67,6 @@ fn contact_state_db_label(state: ContactState) -> &'static str { /// unknown label is a hard error — the migration's `CHECK` constraint /// already rejects writes outside the domain, so reaching this arm /// means on-disk corruption or a forward-incompatible row. -#[cfg(feature = "__test-helpers")] fn contact_state_from_label(label: &str) -> Result { match label { "sent" => Ok(ContactState::Sent), @@ -87,13 +83,10 @@ fn contact_state_from_label(label: &str) -> Result, pub incoming_requests: BTreeMap, @@ -107,7 +100,7 @@ pub struct ContactsRecords { /// precedence — a pending upsert (`sent` / `received`) never downgrades /// an already-`established` row, and an `established` upsert collapses /// any prior pending row for the same pair (both request blobs + the -/// four metadata columns are set, `state = 'established'`). +/// five metadata columns are set, `state = 'established'`). pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -212,7 +205,7 @@ pub fn apply( } if !cs.established.is_empty() { // Establishment collapses any prior pending row for the pair: set - // both request blobs + the four metadata columns and force the + // both request blobs + the five metadata columns and force the // `established` state. let established_label = contact_state_db_label(ContactState::Established); let mut stmt = tx.prepare_cached( @@ -288,14 +281,22 @@ pub fn apply( } /// Build a [`ContactsRecords`] for one wallet from the unified -/// `contacts` table, bucketing rows by `state`. Any row that fails to -/// decode (bad blob, non-32-byte id, unknown state, or a pending row -/// missing its request blob) is a hard error — corruption is never -/// silently dropped. -#[cfg(feature = "__test-helpers")] +/// `contacts` table, bucketing rows by `state`. +/// +/// A row that cannot be read — a bad blob, an unknown state, or a pending +/// row missing the request blob its state requires — is routed through +/// `ctx`: fatal under [`LoadPolicy::Strict`](crate::LoadPolicy::Strict), +/// skipped and counted at [`LoadSite::ContactRow`] under `Recovery`. A +/// contact carries no funds, so losing one costs a contact rather than a +/// balance; that is what makes the row a legitimate unit to skip, where a +/// balance-bearing row would have to take its whole wallet down. +/// +/// A non-32-byte id stays fatal in both policies: it is structural, not a +/// payload the reader can decline. pub(crate) fn load_state( conn: &Connection, wallet_id: &WalletId, + ctx: &LoadCtx, ) -> Result { let mut state = ContactsRecords::default(); @@ -308,81 +309,126 @@ pub(crate) fn load_state( while let Some(row) = rows.next()? { let owner: Vec = row.get(0)?; let contact: Vec = row.get(1)?; - let label: String = row.get(2)?; - let outgoing: Option> = row.get(3)?; - let incoming: Option> = row.get(4)?; - let (owner_id, contact_id) = decode_pair_key(&owner, &contact)?; - - match contact_state_from_label(&label)? { - ContactState::Sent => { - let request = decode_request("outgoing_request", outgoing.as_deref())?; - state.sent_requests.insert( - SentContactRequestKey { - owner_id, - recipient_id: contact_id, - }, - ContactRequestEntry { request }, - ); - } - ContactState::Received => { - let request = decode_request("incoming_request", incoming.as_deref())?; - state.incoming_requests.insert( - ReceivedContactRequestKey { - owner_id, - sender_id: contact_id, - }, - ContactRequestEntry { request }, - ); - } - ContactState::Established => { - let outgoing_request = decode_request("outgoing_request", outgoing.as_deref())?; - let incoming_request = decode_request("incoming_request", incoming.as_deref())?; - let alias: Option = row.get(5)?; - let note: Option = row.get(6)?; - let is_hidden: bool = row.get::<_, Option>(7)?.unwrap_or(0) != 0; - let accepted_blob: Option> = row.get(8)?; - let accepted_accounts: Vec = match accepted_blob { - Some(bytes) => blob::decode(&bytes)?, - None => Vec::new(), - }; - let payment_channel_broken: bool = row.get::<_, Option>(9)?.unwrap_or(0) != 0; - state.established.insert( - SentContactRequestKey { - owner_id, - recipient_id: contact_id, - }, - EstablishedContact { - contact_identity_id: contact_id, - outgoing_request, - incoming_request, - alias, - note, - is_hidden, - accepted_accounts, - payment_channel_broken, - // System-derived incoming-only label; this backend has - // no column for it, so it restores empty and re-derives - // on the next contact-info sweep. - contact_account_label: None, - // Rotation self-heal marker; this backend has no column - // for it, so it restores `None` — which conservatively - // forces the next sweep to re-verify (tear down + rebuild) - // the external account once, then re-stamp it. - external_account_reference: None, - }, - ); - } + let (owner_id, contact_id) = + decode_pair_key("contacts.owner_id", &owner, "contacts.contact_id", &contact)?; + // Every column is read before any is interpreted, so the row's + // decode is one all-or-nothing unit the policy can judge. + let columns = ContactRowColumns { + owner_id, + contact_id, + label: row.get(2)?, + outgoing: row.get(3)?, + incoming: row.get(4)?, + alias: row.get(5)?, + note: row.get(6)?, + is_hidden: row.get::<_, Option>(7)?.unwrap_or(0) != 0, + accepted_accounts: row.get(8)?, + payment_channel_broken: row.get::<_, Option>(9)?.unwrap_or(0) != 0, + }; + if let Err(err) = bucket_contact_row(&mut state, columns) { + ctx.tolerate(LoadSite::ContactRow, err)?; } } Ok(state) } +/// One `contacts` row's columns, read but not yet interpreted. +struct ContactRowColumns { + owner_id: Identifier, + contact_id: Identifier, + label: String, + outgoing: Option>, + incoming: Option>, + alias: Option, + note: Option, + is_hidden: bool, + accepted_accounts: Option>, + payment_channel_broken: bool, +} + +/// File one read row into the bucket its `state` names, decoding the blobs +/// that state requires. Every failure here is a property of the row, which +/// is what lets the caller decide the row's fate rather than the load's. +fn bucket_contact_row( + state: &mut ContactsRecords, + columns: ContactRowColumns, +) -> Result<(), WalletStorageError> { + let ContactRowColumns { + owner_id, + contact_id, + label, + outgoing, + incoming, + alias, + note, + is_hidden, + accepted_accounts, + payment_channel_broken, + } = columns; + + match contact_state_from_label(&label)? { + ContactState::Sent => { + let request = decode_request("outgoing_request", outgoing.as_deref())?; + state.sent_requests.insert( + SentContactRequestKey { + owner_id, + recipient_id: contact_id, + }, + ContactRequestEntry { request }, + ); + } + ContactState::Received => { + let request = decode_request("incoming_request", incoming.as_deref())?; + state.incoming_requests.insert( + ReceivedContactRequestKey { + owner_id, + sender_id: contact_id, + }, + ContactRequestEntry { request }, + ); + } + ContactState::Established => { + let outgoing_request = decode_request("outgoing_request", outgoing.as_deref())?; + let incoming_request = decode_request("incoming_request", incoming.as_deref())?; + let accepted_accounts: Vec = match accepted_accounts { + Some(bytes) => blob::decode(&bytes)?, + None => Vec::new(), + }; + state.established.insert( + SentContactRequestKey { + owner_id, + recipient_id: contact_id, + }, + EstablishedContact { + contact_identity_id: contact_id, + outgoing_request, + incoming_request, + alias, + note, + is_hidden, + accepted_accounts, + payment_channel_broken, + // System-derived incoming-only label; this backend has + // no column for it, so it restores empty and re-derives + // on the next contact-info sweep. + contact_account_label: None, + // Rotation self-heal marker; this backend has no column + // for it, so it restores `None` — which conservatively + // forces the next sweep to re-verify (tear down + rebuild) + // the external account once, then re-stamp it. + external_account_reference: None, + }, + ); + } + } + Ok(()) +} + /// Decode a `ContactRequest` from a nullable request column. A NULL /// column on a state that requires it (a pending row missing its blob, /// or an established row missing either side) is a hard error — the /// shape invariant is part of the on-disk contract. -#[cfg(feature = "__test-helpers")] fn decode_request( column: &'static str, bytes: Option<&[u8]>, @@ -396,14 +442,14 @@ fn decode_request( } } -// Widened to `any(test, …)` alongside `load_ignored_senders`, its only -// `test`-arm caller (the `__test-helpers`-gated readers use it too). -#[cfg(any(test, 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"))?; +fn decode_pair_key( + a_column: &'static str, + a: &[u8], + b_column: &'static str, + b: &[u8], +) -> Result<(Identifier, Identifier), WalletStorageError> { + let a32 = super::id32(a_column, a)?; + let b32 = super::id32(b_column, b)?; Ok((Identifier::from(a32), Identifier::from(b32))) } @@ -414,8 +460,9 @@ fn decode_pair_key(a: &[u8], b: &[u8]) -> Result<(Identifier, Identifier), Walle pub fn load_state_for_test( conn: &Connection, wallet_id: &WalletId, + ctx: &LoadCtx, ) -> Result { - load_state(conn, wallet_id) + load_state(conn, wallet_id, ctx) } /// Read the wallet's `ignored_senders` rows, grouped per owner identity. @@ -430,7 +477,6 @@ pub fn load_state_for_test( /// resurrect un-ignored senders, stickily (the next snapshot re-persists /// the resurrected entry). The identity loader therefore restores the /// ignored set from this reader and disregards the blob field. -#[cfg(any(test, feature = "__test-helpers"))] pub(crate) fn load_ignored_senders( conn: &Connection, wallet_id: &WalletId, @@ -442,7 +488,12 @@ pub(crate) fn load_ignored_senders( while let Some(row) = rows.next()? { let owner: Vec = row.get(0)?; let sender: Vec = row.get(1)?; - let (owner, sender) = decode_pair_key(&owner, &sender)?; + let (owner, sender) = decode_pair_key( + "ignored_senders.owner_id", + &owner, + "ignored_senders.sender_id", + &sender, + )?; map.entry(owner).or_default().insert(sender); } Ok(map) @@ -484,6 +535,18 @@ mod tests { ); } + /// Pins the live domain to the list frozen in `V001__initial.rs`. + /// + /// IF THIS FAILS: do NOT edit V001's list to match. Refinery checksums a + /// migration's rendered SQL, so changing an applied migration's body makes + /// every database that already ran it fail to open, permanently. Append a + /// migration rebuilding the table with the widened CHECK (the + /// `V004__asset_lock_recovered_status.rs` pattern), then update this pin. + #[test] + fn contact_state_labels_frozen_in_v001() { + assert_eq!(CONTACT_STATE_LABELS, &["sent", "received", "established"]); + } + /// Ignoring a sender persists one `ignored_senders` row; un-ignoring the /// same `(owner, sender)` deletes it. This is the local-only suppression /// the sync ingest path relies on — if the write/delete pairing is wrong, @@ -491,7 +554,7 @@ mod tests { #[test] fn ignore_then_unignore_round_trips() { use crate::sqlite::migrations; - use crate::sqlite::schema::wallet_meta; + use crate::sqlite::schema::wallets; use dpp::prelude::Identifier; use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::Connection; @@ -500,7 +563,7 @@ mod tests { let mut conn = Connection::open_in_memory().unwrap(); migrations::run(&mut conn).unwrap(); let wallet_id: WalletId = [7u8; 32]; - wallet_meta::ensure_exists(&conn, &wallet_id).unwrap(); + wallets::ensure_exists(&conn, &wallet_id).unwrap(); let owner = Identifier::from([0xAAu8; 32]); let sender = Identifier::from([0xBBu8; 32]); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs new file mode 100644 index 00000000000..f5b3d4056d3 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs @@ -0,0 +1,739 @@ +//! Writer + account-attribution helper for the `core_address_pool` table. +//! +//! Per-index address-pool rows carrying a `used` flag, scoped by +//! `(wallet_id, account_type, account_index, key_class, user_identity_id, +//! friend_identity_id, pool_type, address_index)` — the DashPay identity pair +//! is in the PK (mirroring `account_registrations`) so distinct contacts on +//! one wallet, which otherwise collapse to the same `(dashpay_receiving, 0)` +//! sentinel, never overwrite each other's pool rows. The first-class row +//! store the reader consumes verbatim — +//! no `core_utxos` script-derivation, no horizon-walk re-derivation. Populated +//! from the `account_address_pools` changeset snapshots; the UTXO writer reads +//! it back to attribute an outpoint to its owning account. + +use rusqlite::{params, Connection, Transaction}; + +use platform_wallet::changeset::AccountAddressPoolEntry; +use platform_wallet::wallet::platform_wallet::WalletId; + +use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; +use crate::sqlite::schema::accounts; +use crate::sqlite::schema::blob; + +/// Stored `pool_type` discriminant. Kept in the primary key so an External +/// and an Internal pool never collide at the same `address_index`. +pub(crate) fn pool_type_to_i64(pool_type: AddressPoolType) -> i64 { + match pool_type { + AddressPoolType::External => 0, + AddressPoolType::Internal => 1, + AddressPoolType::Absent => 2, + AddressPoolType::AbsentHardened => 3, + } +} + +// Stable `PublicKeyType` declaration-order discriminants and emitted lengths. +const KEY_TYPE_ECDSA: i64 = 0; +const KEY_TYPE_EDDSA: i64 = 1; +const KEY_TYPE_BLS: i64 = 2; +const ECDSA_PUBLIC_KEY_LEN: usize = 33; +const EDDSA_PUBLIC_KEY_LEN: usize = 32; +const BLS_PUBLIC_KEY_LEN: usize = 48; + +fn key_type_to_i64(key_type: &PublicKeyType) -> i64 { + match key_type { + PublicKeyType::ECDSA(_) => KEY_TYPE_ECDSA, + PublicKeyType::EdDSA(_) => KEY_TYPE_EDDSA, + PublicKeyType::BLS(_) => KEY_TYPE_BLS, + } +} + +// Monotonic final usage clears reservations so a stale Reserved snapshot +// cannot resurrect one after the row has become Used. +const UPSERT_POOL_SQL: &str = "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id, \ + pool_type, address_index, script, used, public_key, key_type, reserved_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \ + ON CONFLICT(wallet_id, account_type, account_index, key_class, user_identity_id, \ + friend_identity_id, pool_type, address_index) \ + DO UPDATE SET \ + script = excluded.script, \ + public_key = excluded.public_key, \ + key_type = excluded.key_type, \ + used = MAX(used, excluded.used), \ + reserved_at = CASE \ + WHEN MAX(used, excluded.used) = 1 THEN NULL \ + ELSE excluded.reserved_at \ + END"; + +const TYPED_POOL_CONFLICT_SQL: &str = "SELECT EXISTS( \ + SELECT 1 FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2 AND account_index = ?3 \ + AND key_class = ?4 AND user_identity_id = ?5 AND friend_identity_id = ?6 \ + AND pool_type = ?7 AND address_index = ?8 \ + AND key_type IS NOT NULL AND public_key IS NOT NULL \ + AND (?10 IS NULL OR public_key <> ?9 OR key_type <> ?10) \ + )"; + +/// Expand `account_address_pools` snapshots into per-index +/// `core_address_pool` rows. Idempotent: `script` is derivation-stable and +/// `used` is monotonic (`MAX`), so re-applying the same snapshot is a no-op +/// and a used address can never revert to unused (the reuse-guard invariant). +/// Writes reject any change or erasure of typed key material already stored. +/// +/// # Errors +/// +/// [`WalletStorageError::TypedPoolKeyConflict`] when incoming key material +/// contradicts what is already stored, and +/// [`WalletStorageError::EmptyPoolAddressScript`] when a row carries a +/// zero-length `script`: `load()` turns every stored script back into an +/// address, so an empty one degrades the load of the owning wallet and fails +/// it outright under a strict policy. This is the only writer of +/// `core_address_pool.script`, so refusing here closes the producer. +/// +/// The script guard binds new writes only. Empty scripts already present in +/// an existing database file remain fatal to a strict load, and are +/// deliberately left in place rather than purged — the same posture V015 +/// took for the sibling `core_utxos.script` column, where it purged only +/// legacy empty-script *spent* rows and left the surviving balance-bearing +/// ones alone. +pub fn apply_pools( + tx: &Transaction<'_>, + wallet_id: &WalletId, + pools: &[AccountAddressPoolEntry], +) -> Result<(), WalletStorageError> { + if pools.is_empty() { + return Ok(()); + } + let mut conflict_stmt = tx.prepare_cached(TYPED_POOL_CONFLICT_SQL)?; + let mut upsert_stmt = tx.prepare_cached(UPSERT_POOL_SQL)?; + for entry in pools { + // `account_type` discriminates accounts that collapse to the same + // `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` + // and `ProviderVotingKeys`, both `0, 0`); without it they would upsert + // onto the same PK and overwrite each other's rows. + let account_type = accounts::account_type_db_label(&entry.account_type); + let account_index = i64::from(accounts::account_index(&entry.account_type)); + // TODO(key_class): PlatformPayment carries a real key_class; every + // other account maps to the 0 sentinel until the pool snapshot + // threads a per-pool key class. + let key_class = i64::from(accounts::account_key_class(&entry.account_type)); + // DashPay accounts all collapse to (dashpay_receiving/dashpay_external, + // account_index=0); the identity pair is the real per-contact + // discriminator, all-zero for every other account type. + let (user_identity_id, friend_identity_id) = + accounts::account_dashpay_ids(&entry.account_type); + let pool_type = pool_type_to_i64(entry.pool_type); + for info in &entry.addresses { + if info.script_pubkey.as_bytes().is_empty() { + return Err(WalletStorageError::EmptyPoolAddressScript { + account_type, + address_index: info.index, + }); + } + let key_type = info.public_key.as_ref().map(key_type_to_i64); + let (public_key, expected_key_len): (Option<&[u8]>, Option) = + match info.public_key.as_ref() { + None => (None, None), + Some(PublicKeyType::ECDSA(bytes)) => { + (Some(bytes.as_slice()), Some(ECDSA_PUBLIC_KEY_LEN)) + } + Some(PublicKeyType::EdDSA(bytes)) => { + (Some(bytes.as_slice()), Some(EDDSA_PUBLIC_KEY_LEN)) + } + Some(PublicKeyType::BLS(bytes)) => { + (Some(bytes.as_slice()), Some(BLS_PUBLIC_KEY_LEN)) + } + }; + if let (Some(public_key), Some(expected_key_len)) = (public_key, expected_key_len) { + blob::check_fixed_width( + i64::try_from(public_key.len()).unwrap_or(i64::MAX), + expected_key_len, + "core_address_pool.public_key has the wrong length for key_type", + )?; + } + let conflicts = conflict_stmt.query_row( + params![ + wallet_id.as_slice(), + account_type, + account_index, + key_class, + user_identity_id.as_slice(), + friend_identity_id.as_slice(), + pool_type, + i64::from(info.index), + public_key, + key_type, + ], + |row| row.get::<_, bool>(0), + )?; + if conflicts { + return Err(WalletStorageError::TypedPoolKeyConflict { + account_type, + address_index: info.index, + }); + } + let reserved_at = info + .reserved_at() + .map(|at| { + crate::sqlite::util::safe_cast::u64_to_i64("core_address_pool.reserved_at", at) + }) + .transpose()?; + upsert_stmt.execute(params![ + wallet_id.as_slice(), + account_type, + account_index, + key_class, + user_identity_id.as_slice(), + friend_identity_id.as_slice(), + pool_type, + i64::from(info.index), + info.script_pubkey.as_bytes(), + info.is_used(), + public_key, + key_type, + reserved_at, + ])?; + } + } + Ok(()) +} + +// TODO(#4188): `reserved_at` is persisted but deliberately not consumed here; +// restoring it requires widening `provider_accounts::insert_platform_node_pool_entry`. +/// One restored typed-pool row: `(address_index, script_bytes, public_key, used)`. +pub type TypedPoolEntry = (u32, Vec, PublicKeyType, bool); + +/// Load typed public-key rows for one account pool, ordered by address index. +/// +/// These rows carry pre-derived hardened-only batches, such as platform-node +/// EdDSA keys, that cannot be regenerated from a watch-only account xpub. +/// Invalid key discriminants, missing keys, and wrong key widths are errors. +pub fn load_typed_pool_entries( + conn: &Connection, + wallet_id: &WalletId, + account_type: &key_wallet::account::AccountType, + pool_type: AddressPoolType, +) -> Result, WalletStorageError> { + let account_type = accounts::account_type_db_label(account_type); + let pool_type = pool_type_to_i64(pool_type); + let (max_script_len, max_public_key_len): (Option, Option) = conn.query_row( + "SELECT MAX(length(script)), MAX(length(public_key)) FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2 AND pool_type = ?3 \ + AND (public_key IS NOT NULL OR key_type IS NOT NULL)", + params![wallet_id.as_slice(), account_type, pool_type], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if let Some(len) = max_script_len { + blob::check_size(len)?; + } + if let Some(len) = max_public_key_len { + blob::check_size(len)?; + } + + let mut stmt = conn.prepare( + "SELECT address_index, length(script), script, length(public_key), public_key, \ + key_type, used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2 AND pool_type = ?3 \ + AND (public_key IS NOT NULL OR key_type IS NOT NULL) \ + ORDER BY address_index", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice(), account_type, pool_type,])?; + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + let address_index = crate::sqlite::util::safe_cast::i64_to_u32( + "core_address_pool.address_index", + row.get::<_, i64>(0)?, + )?; + blob::check_size(row.get::<_, i64>(1)?)?; + let script = row.get::<_, Vec>(2)?; + let public_key_len = row.get::<_, Option>(3)?; + let key_type = row.get::<_, Option>(5)?; + let (public_key_len, key_type) = match (public_key_len, key_type) { + (Some(public_key_len), Some(key_type)) => (public_key_len, key_type), + _ => { + return Err(WalletStorageError::blob_decode( + "core_address_pool.public_key and key_type nullability differ", + )); + } + }; + let expected_key_len = match key_type { + KEY_TYPE_ECDSA => ECDSA_PUBLIC_KEY_LEN, + KEY_TYPE_EDDSA => EDDSA_PUBLIC_KEY_LEN, + KEY_TYPE_BLS => BLS_PUBLIC_KEY_LEN, + _ => { + return Err(WalletStorageError::blob_decode( + "core_address_pool.key_type is outside 0..=2", + )); + } + }; + blob::check_fixed_width( + public_key_len, + expected_key_len, + "core_address_pool.public_key has the wrong length for key_type", + )?; + let Some(public_key) = row.get::<_, Option>>(4)? else { + return Err(WalletStorageError::blob_decode( + "core_address_pool.public_key is NULL for a typed row", + )); + }; + let public_key = match key_type { + KEY_TYPE_ECDSA => PublicKeyType::ECDSA(public_key), + KEY_TYPE_EDDSA => PublicKeyType::EdDSA(public_key), + KEY_TYPE_BLS => PublicKeyType::BLS(public_key), + _ => { + return Err(WalletStorageError::blob_decode( + "core_address_pool.key_type is outside 0..=2", + )); + } + }; + out.push((address_index, script, public_key, row.get(6)?)); + } + Ok(out) +} + +/// Identity of the funds account that owns an address, matched against a +/// `core_address_pool` row. Enough to select one account among funding accounts +/// that share a numeric `account_index` (Standard BIP44/BIP32 and CoinJoin can +/// all sit at index 0; DashPay accounts all persist index 0 and are told apart +/// by the identity pair). +/// +/// Carries four of the writer's five pool-PK account discriminators +/// (`UPSERT_POOL_SQL`); `key_class` is intentionally omitted. Every funds +/// account maps to the `key_class = 0` sentinel — only the non-funds +/// `PlatformPayment` account carries a real class, and it is never a funding +/// account — so `key_class` cannot distinguish two funds accounts and adds +/// nothing here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwningAccount { + /// `account_type` DB label (see [`accounts::account_type_db_label`]). + pub account_type: String, + /// Numeric account index within its type. + pub account_index: u32, + /// DashPay owner identity (all-zero for non-DashPay accounts). + pub user_identity_id: [u8; 32], + /// DashPay contact identity (all-zero for non-DashPay accounts). + pub friend_identity_id: [u8; 32], +} + +fn validate_account_type(label: &str) -> Result<(), WalletStorageError> { + if accounts::ACCOUNT_TYPE_LABELS.contains(&label) || label == accounts::LEGACY_STANDARD_LABEL { + Ok(()) + } else { + Err(WalletStorageError::blob_decode( + "core_address_pool.account_type is unknown", + )) + } +} + +/// Full owning-account identity for a UTXO, matched by its `script_pubkey` +/// against a pool row. `None` when no pool row covers the script. +pub(crate) fn owning_account_for_script( + conn: &Connection, + wallet_id: &WalletId, + script: &[u8], +) -> Result, WalletStorageError> { + // A script can appear under several pool rows (distinct account_type / + // key_class / identity pair / pool_type share the same `script_pubkey` + // for reused keys); an explicit PK-ordered tie-break makes the pick + // deterministic instead of relying on SQLite's arbitrary `LIMIT 1` row. + let mut stmt = conn.prepare_cached( + "SELECT account_type, account_index, length(user_identity_id), user_identity_id, \ + length(friend_identity_id), friend_identity_id \ + FROM core_address_pool \ + WHERE wallet_id = ?1 AND script = ?2 \ + ORDER BY account_type, account_index, key_class, user_identity_id, \ + friend_identity_id, pool_type, address_index ASC \ + LIMIT 1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice(), script])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + let account_type = row.get::<_, String>(0)?; + validate_account_type(&account_type)?; + let index = row.get::<_, i64>(1)?; + blob::check_fixed_width( + row.get::<_, i64>(2)?, + 32, + "core_address_pool.user_identity_id", + )?; + let user = row.get::<_, Vec>(3)?; + blob::check_fixed_width( + row.get::<_, i64>(4)?, + 32, + "core_address_pool.friend_identity_id", + )?; + let friend = row.get::<_, Vec>(5)?; + let account_index = + crate::sqlite::util::safe_cast::i64_to_u32("core_address_pool.account_index", index)?; + let user_identity_id = super::id32("core_address_pool.user_identity_id", &user)?; + let friend_identity_id = super::id32("core_address_pool.friend_identity_id", &friend)?; + Ok(Some(OwningAccount { + account_type, + account_index, + user_identity_id, + friend_identity_id, + })) +} + +/// Used addresses for a wallet, read verbatim from `core_address_pool` +/// (`used = 1`), each paired with its owning account. This is the +/// *known-owner* reuse-guard source: the pool row carries the account +/// discriminators directly, so no per-script round-trip is needed. Possibly +/// empty. The caller **unions** this with the `core_utxos`-derived set — the +/// reuse guard is monotonic, so a mixed store (historical UTXOs a later +/// partial pool snapshot never enumerates) must surface both sources, never +/// drop the historical ones. +/// +/// A `script` reused across accounts yields several rows; this reader applies +/// the same PK-ordered tie-break as [`owning_account_for_script`] among rows +/// marked used. An unused row can therefore own a UTXO while a different used +/// row supplies the reuse guard, which preserves each resolver's distinct +/// source contract. `network` turns each stored `script` back into an +/// [`Address`](dashcore::Address); an invalid script is fatal under Strict and +/// skipped under Recovery, matching +/// [`crate::sqlite::schema::core_state::load_used_addresses`]. +/// This compatibility entry point uses Strict; rehydration calls +/// [`load_used_addresses_with_ctx`] with its policy context. +/// Unknown account labels fail the wallet under either policy: dropping a +/// used address would lose its reuse guard. Recovery isolates that wallet. +pub fn load_used_addresses( + conn: &rusqlite::Connection, + wallet_id: &WalletId, + network: dashcore::Network, +) -> Result, WalletStorageError> { + load_used_addresses_with_ctx(conn, wallet_id, network, &LoadCtx::strict()) +} + +/// [`load_used_addresses`] under an explicit load policy. +pub fn load_used_addresses_with_ctx( + conn: &rusqlite::Connection, + wallet_id: &WalletId, + network: dashcore::Network, + ctx: &LoadCtx, +) -> Result, WalletStorageError> { + // Gate the largest stored `script` with a cheap aggregate BEFORE the + // ordered read materializes or sorts any blob, so a corrupt/oversize + // column raises a typed `BlobTooLarge` (the crate's 16 MiB cap) rather + // than SQLite's own `TooBig` mid-sort, and never OOMs the host. + blob::check_max_column_len( + conn, + "SELECT MAX(length(script)) FROM core_address_pool \ + WHERE wallet_id = ?1 AND used = 1", + wallet_id, + )?; + // Order by script then the pool PK so the first row per script is the + // tie-break winner; a `HashSet` on script drops the trailing duplicates. + let mut stmt = conn.prepare( + "SELECT script, account_type, account_index, \ + length(user_identity_id), user_identity_id, \ + length(friend_identity_id), friend_identity_id \ + FROM core_address_pool \ + WHERE wallet_id = ?1 AND used = 1 \ + ORDER BY script, account_type, account_index, key_class, user_identity_id, \ + friend_identity_id, pool_type, address_index", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + let raw_script = row.get::<_, Vec>(0)?; + let account_type = row.get::<_, String>(1)?; + validate_account_type(&account_type)?; + let index = row.get::<_, i64>(2)?; + blob::check_fixed_width( + row.get::<_, i64>(3)?, + 32, + "core_address_pool.user_identity_id", + )?; + let user = row.get::<_, Vec>(4)?; + blob::check_fixed_width( + row.get::<_, i64>(5)?, + 32, + "core_address_pool.friend_identity_id", + )?; + let friend = row.get::<_, Vec>(6)?; + if !seen.insert(raw_script.clone()) { + continue; + } + let address = match blob::decode_script_to_address(raw_script, network) { + Ok(address) => address, + Err(error) => { + ctx.tolerate(LoadSite::UndecodableAddressScript, error)?; + continue; + } + }; + let account_index = + crate::sqlite::util::safe_cast::i64_to_u32("core_address_pool.account_index", index)?; + let user_identity_id = super::id32("core_address_pool.user_identity_id", &user)?; + let friend_identity_id = super::id32("core_address_pool.friend_identity_id", &friend)?; + out.push(( + address, + OwningAccount { + account_type, + account_index, + user_identity_id, + friend_identity_id, + }, + )); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// In-memory connection with the full schema migrated in, so tests insert + /// through the production DDL. + fn migrated_conn() -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + #[test] + fn should_accept_current_and_legacy_pool_account_labels() { + use dashcore::hashes::Hash; + + let conn = migrated_conn(); + let wallet = [0x79; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet.as_slice()], + ) + .unwrap(); + let address = dashcore::Address::new( + dashcore::Network::Testnet, + dashcore::address::Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array([7; 20])), + ); + let script = address.script_pubkey(); + for label in accounts::ACCOUNT_TYPE_LABELS + .iter() + .copied() + .chain(std::iter::once(accounts::LEGACY_STANDARD_LABEL)) + { + conn.execute("DELETE FROM core_address_pool", []).unwrap(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, ?2, 0, 0, 0, 0, ?3, 1)", + params![wallet.as_slice(), label, script.as_bytes()], + ) + .unwrap(); + let owner = owning_account_for_script(&conn, &wallet, script.as_bytes()) + .unwrap() + .unwrap(); + assert_eq!(owner.account_type, label); + let used = load_used_addresses(&conn, &wallet, dashcore::Network::Testnet).unwrap(); + assert_eq!(used, vec![(address.clone(), owner)]); + } + } + + /// UTXO ownership considers every pool row, while the reuse guard selects + /// only rows explicitly marked used. + #[test] + fn shared_script_resolvers_apply_their_distinct_source_filters() { + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use dashcore::PubkeyHash; + + let mut conn = migrated_conn(); + let w = [0x77u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + let address = dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0xAB; 20])), + ); + let script = address.script_pubkey(); + let tx = conn.transaction().unwrap(); + // Same script under two account types with different account_index. + // Insert the later-sorting `standard_bip44` FIRST so a bare `LIMIT 1` + // could return either row depending on SQLite's scan order. + tx.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 9, 0, 0, 0, ?2, 1)", + params![&w[..], script.as_bytes()], + ) + .unwrap(); + tx.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'coinjoin', 4, 0, 0, 0, ?2, 0)", + params![&w[..], script.as_bytes()], + ) + .unwrap(); + // ORDER BY account_type ASC: 'coinjoin' < 'standard_bip44', so the + // coinjoin row (account_index 4) is the deterministic winner. + let got = owning_account_for_script(&tx, &w, script.as_bytes()) + .unwrap() + .expect("shared script owner"); + assert_eq!( + got.account_index, 4, + "tie-break must pick the account_type-min row" + ); + tx.commit().unwrap(); + + let used = + load_used_addresses_with_ctx(&conn, &w, dashcore::Network::Testnet, &LoadCtx::strict()) + .unwrap(); + assert_eq!(used.len(), 1); + assert_eq!(used[0].0, address); + assert_eq!(used[0].1.account_index, 9); + } + + /// A stored `script` that parses as bytes but not as an address must + /// surface `AddressDecode` — carrying the upstream + /// `dashcore::address::Error` — not the context-free `BlobDecode` that + /// discards *why* the script failed. + #[test] + fn load_used_addresses_wraps_address_error_as_address_decode() { + let conn = migrated_conn(); + let w = [0x88u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + // A bare OP_RETURN script is well-formed bytes but not any address + // type, so `Address::from_script` returns `UnrecognizedScript`. + let bad_script = [0x6au8]; + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'coinjoin', 0, 0, 0, 0, ?2, 1)", + params![&w[..], &bad_script[..]], + ) + .unwrap(); + + let err = + load_used_addresses_with_ctx(&conn, &w, dashcore::Network::Testnet, &LoadCtx::strict()) + .expect_err("an unparseable script must be a hard error"); + assert!( + matches!(err, WalletStorageError::AddressDecode { .. }), + "expected AddressDecode carrying the upstream error, got {err:?}" + ); + } + + /// An empty `script` must be refused by the WRITER, not discovered by + /// the reader: `load()` turns every stored pool script back into an + /// address, so such a row degrades the load of the owning wallet and + /// fails it under a strict policy. `apply_pools` is the only writer of + /// `core_address_pool.script`, so guarding it closes the producer. + #[test] + fn apply_pools_refuses_an_empty_script() { + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::bip32::DerivationPath; + use key_wallet::managed_account::address_pool::AddressState; + use key_wallet::AddressInfo; + use platform_wallet::changeset::AccountAddressPoolEntry; + + let mut conn = migrated_conn(); + let w = [0x99u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + let address = dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array([0xCD; 20])), + ); + let entry = AccountAddressPoolEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + pool_type: AddressPoolType::External, + addresses: vec![AddressInfo { + address, + script_pubkey: dashcore::ScriptBuf::from_bytes(Vec::new()), + public_key: None, + index: 7, + path: DerivationPath::master(), + state: AddressState::Available, + tx_count: 0, + total_received: 0, + total_sent: 0, + balance: 0, + label: None, + metadata: Default::default(), + }], + }; + + let tx = conn.transaction().unwrap(); + let err = apply_pools(&tx, &w, std::slice::from_ref(&entry)) + .expect_err("an empty script must be refused"); + match err { + WalletStorageError::EmptyPoolAddressScript { + account_type, + address_index, + } => { + assert_eq!(account_type, "standard_bip44"); + assert_eq!(address_index, 7, "the error must name the offending row"); + } + other => panic!("expected EmptyPoolAddressScript, got {other:?}"), + } + let rows: i64 = tx + .query_row("SELECT COUNT(*) FROM core_address_pool", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(rows, 0, "the guard must refuse before binding the row"); + } + + #[test] + fn integer_discriminants_match_migration_domains() { + use std::collections::BTreeSet; + + let pool_variants = [ + AddressPoolType::External, + AddressPoolType::Internal, + AddressPoolType::Absent, + AddressPoolType::AbsentHardened, + ]; + for variant in pool_variants { + match variant { + AddressPoolType::External + | AddressPoolType::Internal + | AddressPoolType::Absent + | AddressPoolType::AbsentHardened => {} + } + } + let pool_discriminants: BTreeSet<_> = pool_variants + .iter() + .copied() + .map(pool_type_to_i64) + .collect(); + assert_eq!(pool_discriminants, BTreeSet::from([0, 1, 2, 3])); + assert_eq!(pool_discriminants.len(), pool_variants.len()); + + let key_variants = [ + PublicKeyType::ECDSA(Vec::new()), + PublicKeyType::EdDSA(Vec::new()), + PublicKeyType::BLS(Vec::new()), + ]; + for variant in &key_variants { + match variant { + PublicKeyType::ECDSA(_) | PublicKeyType::EdDSA(_) | PublicKeyType::BLS(_) => {} + } + } + let key_discriminants: BTreeSet<_> = key_variants.iter().map(key_type_to_i64).collect(); + assert_eq!(key_discriminants, BTreeSet::from([0, 1, 2])); + assert_eq!(key_discriminants.len(), key_variants.len()); + } +} 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 index 8ead31cb855..77d4808ed75 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -2,10 +2,11 @@ #[cfg(any(test, feature = "__test-helpers"))] use std::collections::BTreeMap; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use rusqlite::{params, Connection, OptionalExtension, Transaction}; +use dashcore::ephemerealdata::chain_lock::ChainLock; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; @@ -13,9 +14,67 @@ use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob; +use crate::sqlite::schema::blob::impl_persistable_blob; +use crate::sqlite::schema::core_pool::{owning_account_for_script, OwningAccount}; + +// PUBLIC material only: core-chain state reaching `record_blob` / +// `islock_blob` (transaction records + InstantLocks are public chain data). +impl_persistable_blob!(TransactionRecord, dashcore::InstantLock); + +/// Encode a `ChainLock` to bytes for storage in `core_sync_state`. +fn encode_chain_lock(cl: &ChainLock) -> Result, WalletStorageError> { + Ok(bincode::encode_to_vec(cl, blob::bounded_config())?) +} + +/// Decode a `ChainLock` from `core_sync_state.last_applied_chain_lock`. +/// +/// Error mapping mirrors [`blob::decode`]: an over-cap payload is +/// [`WalletStorageError::BlobTooLarge`], trailing bytes after the typed +/// length are a `BlobDecode`, and anything else keeps the upstream bincode +/// error as its source. +/// +/// # Errors +/// +/// Under [`LoadPolicy::Strict`](crate::LoadPolicy) any of the above aborts +/// the load. Under `Recovery` they are counted and the field is left +/// `None`, which the next ChainLock sync repopulates. `BlobTooLarge` is +/// fatal in both — recovery tolerates inconsistent rows, not oversize +/// allocations. +fn decode_chain_lock(bytes: &[u8], ctx: &LoadCtx) -> Result, WalletStorageError> { + let failure = match bincode::decode_from_slice::(bytes, blob::bounded_config()) { + Ok((cl, consumed)) if consumed == bytes.len() => return Ok(Some(cl)), + Ok(_) => WalletStorageError::blob_decode( + "unexpected trailing bytes in core_sync_state.last_applied_chain_lock", + ), + Err(bincode::error::DecodeError::LimitExceeded) => { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: bytes.len(), + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }) + } + Err(other) => WalletStorageError::from(other), + }; + ctx.tolerate(LoadSite::ChainLockBlob, failure)?; + Ok(None) +} + +/// Block height of an encoded `last_applied_chain_lock` blob, or `None` if it +/// can't be decoded. Used to monotonic-max-merge the chain lock so an +/// out-of-order lower-height update never regresses the finalized checkpoint. +fn chain_lock_height(bytes: &[u8]) -> Option { + match bincode::decode_from_slice::(bytes, blob::bounded_config()) { + // Require full consumption (like `decode_chain_lock`) so a corrupt + // stored blob can't out-rank a later valid update and stay stuck. + Ok((cl, consumed)) if consumed == bytes.len() => Some(cl.block_height), + _ => None, + } +} /// Apply a `CoreChangeSet` inside a transaction. +/// +/// Recordless UTXOs write monotonic height-only rows; transaction records win. pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -51,61 +110,61 @@ pub fn apply( ])?; } } - // 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", + // `addresses_derived` is intentionally NOT persisted here — the pool + // snapshot (`account_address_pools`) is the derived-address source used + // to resolve UTXO ownership during rehydration. + if !cs.new_utxos.is_empty() { + // Blob-bearing records always win; height-only writes never overwrite them. + // Placeholder confirmation is `height IS NOT NULL`; `finalized` stays 0. + let mut height_only_stmt = tx.prepare_cached( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, ?3, NULL, NULL, 0, NULL) \ + ON CONFLICT(wallet_id, txid) DO UPDATE SET height = \ + CASE \ + WHEN excluded.height IS NOT NULL \ + AND (core_transactions.height IS NULL \ + OR excluded.height > core_transactions.height) \ + THEN excluded.height \ + ELSE core_transactions.height \ + END \ + WHERE core_transactions.record_blob IS NULL", )?; - 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![ + let mut utxo_stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; + for utxo in &cs.new_utxos { + let affected = height_only_stmt.execute(params![ wallet_id.as_slice(), - account_type, - i64::from(account_index), - address, - path, - false + AsRef::<[u8]>::as_ref(&utxo.outpoint.txid), + utxo.is_confirmed.then_some(i64::from(utxo.height)), ])?; - } - } - 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 affected == 0 { + tracing::debug!( + txid = %utxo.outpoint.txid, + "existing transaction record blocked a stale height-only write; \ + refresh the record itself to update its confirmation height" + ); + } + execute_upsert_utxo(&mut utxo_stmt, wallet_id, utxo, false)?; } } if !cs.spent_utxos.is_empty() { - // Only a MATERIALISED row takes the in-place fast path. A - // never-materialised placeholder (`height IS NULL`, `apply_sweep`'s + // Only a materialized row takes the in-place fast path. A + // never-materialised placeholder (`is_sweep_placeholder = 1`, `apply_sweep`'s // tombstone) must go through the full upsert instead: the wallet // delivering the coin as spent is a delivery, and the collector's // soundness argument assumes every delivery materialises the row. - // Marking the placeholder in place would leave `height` NULL and + // Marking the placeholder in place would leave the placeholder flag set and // the stamp intact, so `collect_finalized_tombstones` would delete // the only durable record of the spend once the boundary passed — // and a later rescan re-delivery would land the coin unspent. let mut materialised_stmt = tx.prepare_cached( "SELECT 1 FROM core_utxos \ - WHERE wallet_id = ?1 AND outpoint = ?2 AND height IS NOT NULL", + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", )?; 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 materialised: bool = materialised_stmt @@ -115,15 +174,7 @@ pub fn apply( if materialised { mark_spent_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; } else { - // Missing row or held placeholder. For a missing row this - // is the 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`. For a placeholder the - // conflict clause materialises it with the delivered - // funding data, keeps it spent, and clears the stamp. - execute_upsert_utxo(&mut upsert_stmt, &mut lookup_stmt, wallet_id, utxo, true)?; + execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, true)?; } } } @@ -150,11 +201,17 @@ pub fn apply( || cs.synced_height.is_some() || chainlock_height.is_some(); if heights_advanced { + let cl_bytes = cs + .last_applied_chain_lock + .as_ref() + .map(encode_chain_lock) + .transpose()?; upsert_sync_state( tx, wallet_id, cs.last_processed_height, cs.synced_height, + cl_bytes, chainlock_height, )?; } @@ -289,8 +346,7 @@ pub fn apply( if !released.is_empty() { // A released claim that never materialised is deleted outright // rather than flipped to `spent = 0`: the row is all placeholder - // (`value = 0`, `script = X''`, `height` NULL — no writer but the - // tombstone insert leaves `height` NULL), so releasing it in + // (`value = 0`, `script = X''`, placeholder flag set), so releasing it in // place would surface a zero-value phantom coin through // `list_unspent_utxos`. No row is the correct end state — if the // funding output ever classifies, its ordinary upsert creates @@ -312,7 +368,7 @@ pub fn apply( tx.prepare_cached("DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2")?; let mut release_drop_stmt = tx.prepare_cached( "DELETE FROM core_utxos \ - WHERE wallet_id = ?1 AND outpoint = ?2 AND height IS NULL", + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 1", )?; let mut release_stmt = tx.prepare_cached( "UPDATE core_utxos SET spent = 0, spent_in_txid = NULL \ @@ -391,7 +447,7 @@ fn surviving_stored_input_claims( use dashcore::hashes::Hash; let mut stmt = - tx.prepare_cached("SELECT txid, record_blob FROM core_transactions WHERE wallet_id = ?1")?; + tx.prepare_cached("SELECT txid, record_blob FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL")?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut claims: HashSet = HashSet::new(); while let Some(row) = rows.next()? { @@ -461,7 +517,7 @@ fn surviving_stored_input_claims( /// placeholder the real funding data overwrites on arrival. /// `execute_upsert_utxo`'s conflict clause is what makes that placeholder /// durable — it refuses to clear `spent` on a never-materialised held row -/// (`height IS NULL AND spent = 1`), so the claim survives the funding +/// (`is_sweep_placeholder = 1 AND spent = 1`), so the claim survives the funding /// upsert instead of being upserted away by it. The hold is keyed on that /// shape rather than on `spent_in_txid`, which the /// `setnull_core_utxos_on_tx_delete` trigger can clear underneath it (see @@ -497,7 +553,7 @@ fn surviving_stored_input_claims( /// through proof: the funding upsert materialises it (a wallet-owned /// claim — DIP-10 eligibility means the funding tx is mined or will mine, /// and BIP158 matches its block by our script, so delivery is guaranteed; -/// the row gains a real `height` and becomes an ordinary spent coin), a +/// the row clears its placeholder flag and becomes an ordinary spent coin), a /// later block-context sweep re-points it and stamps it into the /// collectible set, or a release deletes it. /// @@ -534,7 +590,7 @@ fn apply_sweep( ) -> Result<(), WalletStorageError> { let loser_blob: Option> = tx .query_row( - "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2 AND record_blob IS NOT NULL", params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], |row| row.get(0), ) @@ -551,6 +607,16 @@ fn apply_sweep( params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], )?; let Some(loser_blob) = loser_blob else { + // Height-only records carry no input list, but their outputs still belong to the loser. + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + )?; + // `encode_outpoint_txid_occupies_bytes_two_to_thirty_three` pins this prefix. + tx.execute( + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND substr(outpoint, 2, 32) = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + )?; return Ok(()); }; let loser: TransactionRecord = blob::decode(&loser_blob)?; @@ -592,7 +658,7 @@ fn apply_sweep( // `spent_in_txid` moves with `spent`: a released input clears back to // NULL (nobody's claim), a held one is attributed to `superseded_by` so // the claim outlives this row's own deletion below. - // A held, never-materialised claim (`height IS NULL`) is re-stamped + // A held, never-materialised claim (`is_sweep_placeholder = 1`) is re-stamped // with the NEW winner's mined height when this sweep has one — the // claim now belongs to that winner, and its height is what the // collector compares against the finality boundary. An IS-locked @@ -603,17 +669,17 @@ fn apply_sweep( // the funding output of a spent outpoint is mined at or below the // height of ANY block-context spender of it, so the boundary passing // that height still proves the funding was delivered or never will be. - // Materialised rows (`height` set) keep their NULL stamp — they are + // Materialised rows (placeholder flag clear) keep their NULL stamp — they are // outside the collector's reach either way. let mut spend_stmt = tx.prepare_cached( "UPDATE core_utxos SET spent = ?3, spent_in_txid = ?4, \ winner_mined_height = CASE \ - WHEN ?3 AND height IS NULL THEN COALESCE(?5, winner_mined_height) \ + WHEN ?3 AND is_sweep_placeholder = 1 THEN COALESCE(?5, winner_mined_height) \ ELSE winner_mined_height END \ WHERE wallet_id = ?1 AND outpoint = ?2", )?; // Only reached for a held input with no existing row — see the doc - // comment above. `value`/`script`/`height`/`account_index` are + // comment above. `value`/`script` are // placeholders; the funding UTXO's own upsert overwrites them (and, // thanks to the held-placeholder valve in `execute_upsert_utxo`, does // not clear `spent` while doing it). `winner_mined_height` is the @@ -624,9 +690,9 @@ fn apply_sweep( // block-context sweep stamps it, or a release deletes it. let mut tombstone_stmt = tx.prepare_cached( "INSERT INTO core_utxos \ - (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid, \ + (wallet_id, outpoint, value, script, is_sweep_placeholder, spent, spent_in_txid, \ winner_mined_height) \ - VALUES (?1, ?2, 0, X'', NULL, 0, 1, ?3, ?4)", + VALUES (?1, ?2, 0, X'', 1, 1, ?3, ?4)", )?; for input in &loser.transaction.input { let outpoint = input.previous_output; @@ -695,98 +761,46 @@ fn apply_sweep( 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"; - -// The valve: `spent` keeps the stored value only for a held placeholder — -// `height IS NULL AND spent = 1`, the row `apply_sweep`'s tombstone insert -// writes for an input the loser claimed but the funding row hadn't arrived -// for yet. The funding upsert (this statement) is exactly the arrival that -// tombstone exists to survive, so it must not double as the thing that -// erases it. -// -// The valve is keyed on the row's SHAPE, not on `spent_in_txid`, for two -// reasons. A materialised row (`height` set) is the wallet's own coin: it -// knows the funding, and any network-final spender of a coin it knows is -// wallet-relevant by definition (BIP158 matches the input's prevout -// script), so the wallet's own scan re-discovers the spend and its view of -// `spent` is authoritative — if it re-delivers such a coin unspent, the -// spender was reorged out and holding the row would lock a real coin out -// forever. And `spent_in_txid` is not a durable key even on a placeholder: -// `setnull_core_utxos_on_tx_delete` nulls it whenever the named winner's -// own `core_transactions` row goes — including when that winner is itself -// swept later and the placeholder was not one of its inputs, so the input -// loop never re-points it — while the hold must outlive that. -// -// `spent_in_txid` follows the same rule: untouched while the valve holds -// (it carries the claim forward), kept when the wallet itself says spent, -// and cleared with `spent` when the wallet hands a materialised coin back. -// `winner_mined_height` always clears: this statement binds a real funding -// `height`, so the row it lands on is materialised from here on — -// permanently outside `collect_finalized_tombstones`'s reach — and a stale -// stamp would only mislead. 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) \ + (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, ?3, ?4, ?5) \ ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \ value = excluded.value, \ script = excluded.script, \ - height = excluded.height, \ - account_index = excluded.account_index, \ + is_sweep_placeholder = 0, \ winner_mined_height = NULL, \ - spent = CASE WHEN core_utxos.height IS NULL AND core_utxos.spent \ + spent = CASE WHEN core_utxos.is_sweep_placeholder = 1 AND core_utxos.spent \ THEN 1 ELSE excluded.spent END, \ spent_in_txid = CASE \ - WHEN core_utxos.height IS NULL AND core_utxos.spent THEN core_utxos.spent_in_txid \ + WHEN core_utxos.is_sweep_placeholder = 1 AND core_utxos.spent THEN core_utxos.spent_in_txid \ WHEN excluded.spent THEN core_utxos.spent_in_txid \ ELSE NULL END"; +/// Upsert one `core_utxos` row; `spent` marks spent-only synthetic rows. +/// +/// # Errors +/// +/// [`WalletStorageError::EmptyUtxoScript`] when the script is empty. This +/// writes materialized `core_utxos.script` values, so refusing here is what +/// keeps the reader's `Address::from_script` reachable only for scripts +/// that can exist — a stored empty one fails the load of the whole file. fn execute_upsert_utxo( stmt: &mut rusqlite::CachedStatement<'_>, - lookup_stmt: &mut rusqlite::CachedStatement<'_>, wallet_id: &WalletId, utxo: &Utxo, spent: bool, ) -> Result<(), WalletStorageError> { + if utxo.txout.script_pubkey.as_bytes().is_empty() { + return Err(WalletStorageError::EmptyUtxoScript { + outpoint: utxo.outpoint, + }); + } 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 looked_up: Option = lookup_stmt - .query_row(params![wallet_id.as_slice(), &address], |row| row.get(0)) - .optional()?; - let account_index: i64 = match looked_up { - Some(idx) => idx, - // An unspent UTXO whose address we never derived would land in - // the wallet's funds under account 0 and never re-derive — silent - // mis-bucketing of live money. Refuse it. The spent-only - // placeholder path tolerates the fallback because spent rows are - // excluded from `list_unspent_utxos`, so a wrong index there is - // inert. - None if !spent => { - return Err(WalletStorageError::UtxoAddressNotDerived { - address: address.clone(), - }); - } - None => { - tracing::debug!( - wallet_id = %hex::encode(wallet_id), - address = %address, - "spent-only UTXO address not found in core_derived_addresses; using account_index 0 placeholder" - ); - 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(()) @@ -797,35 +811,335 @@ fn upsert_sync_state( wallet_id: &WalletId, last_processed: Option, synced: Option, + chain_lock_bytes: Option>, chainlock: Option, ) -> Result<(), WalletStorageError> { - // Monotonic-max semantics — keep the larger of (current, new). - let current = read_sync_heights(tx, wallet_id)?; - let max_or = |a: Option, b: Option| match (a, b) { + // Read current row for monotonic-max height merge + to carry forward any + // existing chain lock when the changeset doesn't include a new one. + let current_raw: (Option, Option, Option>) = tx + .query_row( + "SELECT last_processed_height, synced_height, last_applied_chain_lock \ + FROM core_sync_state WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()? + .unwrap_or((None, None, None)); + // Monotonic-max semantics for sync watermarks. + let current = ( + height_column_u32("core_sync_state.last_processed_height", current_raw.0)?, + height_column_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 lp = max_or(current.0, last_processed); - let sy = max_or(current.1, synced); - let cl = max_or(current.2, chainlock); + let sy = match (current.1, synced) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + }; + // Chain lock: monotonic-max by height like the sync watermarks above. + // A new chain lock replaces the stored one only when its height is >= + // the stored height, so an out-of-order lower-height update can't + // regress the finalized checkpoint. `None` (no update) keeps existing. + let cl_final = match (chain_lock_bytes, current_raw.2) { + (Some(new_bytes), Some(existing_bytes)) => { + if chain_lock_height(&new_bytes) >= chain_lock_height(&existing_bytes) { + Some(new_bytes) + } else { + Some(existing_bytes) + } + } + (Some(new_bytes), None) => Some(new_bytes), + (None, existing) => existing, + }; + let existing_height = read_sync_heights(tx, wallet_id)?.2; + let cl = existing_height.into_iter().chain(chainlock).max(); tx.execute( "INSERT INTO core_sync_state \ - (wallet_id, last_processed_height, synced_height, chainlock_height) \ - VALUES (?1, ?2, ?3, ?4) \ + (wallet_id, last_processed_height, synced_height, last_applied_chain_lock, chainlock_height) \ + VALUES (?1, ?2, ?3, ?4, ?5) \ ON CONFLICT(wallet_id) DO UPDATE SET \ last_processed_height = excluded.last_processed_height, \ synced_height = excluded.synced_height, \ + last_applied_chain_lock = excluded.last_applied_chain_lock, \ chainlock_height = excluded.chainlock_height", params![ wallet_id.as_slice(), lp.map(i64::from), sy.map(i64::from), + cl_final, cl.map(i64::from), ], )?; Ok(()) } +/// Bulk-reconstruct the keyless [`CoreChangeSet`] projection for one wallet +/// from the `core_*` tables, plus the per-outpoint owning-account side channel. +/// PUBLIC material only; mints no `Wallet`. `network` (from `wallets`) turns a +/// persisted `script` back into an `Address`. +/// +/// [`CoreChangeSet::new_utxos`] cannot carry each UTXO's owning account (it is a +/// bare `Vec`), so the returned map surfaces, per unspent outpoint, the +/// funds account that owns it — resolved by matching the UTXO's script against +/// `core_address_pool`. [`apply_persisted_core_state`](crate::sqlite::rehydrate::apply_persisted_core_state) +/// consumes it to route each UTXO to its true account. An outpoint whose script +/// matches no pool row is absent from the map and falls back to the first funds +/// account (the one-way historical-attribution default; re-warms on next sync). +/// +/// # Reconstructed (safety-critical-correct) +/// +/// - **Unspent UTXOs** (`new_utxos`): every `spent = 0` row — the balance +/// source (no-silent-zero); confirmation height comes from the matching +/// `core_transactions` row. A missing row or height loads as unconfirmed. +/// - **Transaction records**: height-only rows supply UTXO confirmation +/// metadata but are not emitted as records. Blob-bearing rows are decoded +/// and checked against their typed txid and height columns. +/// - **IS-locks** / **sync watermarks**: decoded bit-exact, fail-hard on a +/// corrupt blob. +/// +/// # Deferred to the first post-load `sync` (safe re-warm) +/// +/// - **`is_coinbase` / `is_instantlocked` / `is_trusted` / `used` flags**: not +/// carried by `core_utxos`; defaulted and refreshed on the next scan. +pub fn load_state( + conn: &Connection, + wallet_id: &WalletId, + network: dashcore::Network, + ctx: &LoadCtx, +) -> Result< + ( + CoreChangeSet, + std::collections::HashMap, + ), + WalletStorageError, +> { + let mut cs = CoreChangeSet::default(); + let mut utxo_accounts: HashMap = HashMap::new(); + + let mut transaction_heights: HashMap> = HashMap::new(); + let mut blob_backed_transaction_heights = HashSet::new(); + { + use dashcore::hashes::Hash; + + // Pre-read length gates keep fixed-width txids and record blobs from + // being materialized before their stored sizes are validated. + let mut stmt = conn.prepare( + "SELECT length(txid), txid, height, length(record_blob), record_blob \ + FROM core_transactions WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get::<_, i64>(0)?, 32, "core_transactions.txid")?; + let txid_bytes: Vec = row.get(1)?; + let txid = dashcore::Txid::from_slice(&txid_bytes)?; + let height = + height_column_u32("core_transactions.height", row.get::<_, Option>(2)?)?; + let mut effective_txid = txid; + let mut effective_height = height; + if let Some(record_blob_len) = row.get::<_, Option>(3)? { + blob::check_size(record_blob_len)?; + let payload: Vec = row.get(4)?; + let record = blob::decode::(&payload)?; + effective_txid = record.txid; + effective_height = record.block_info().map(|block_info| block_info.height()); + if let Err(mismatch) = + ensure_transaction_record_matches_columns(&txid, height, &record) + { + // The blob is authoritative, so the projection keeps + // using it; the typed columns are left exactly as found. + ctx.tolerate(LoadSite::CoreTransactionColumnDrift, mismatch)?; + } + cs.records.push(record); + transaction_heights.insert(effective_txid, effective_height); + blob_backed_transaction_heights.insert(effective_txid); + } else if !blob_backed_transaction_heights.contains(&effective_txid) { + transaction_heights + .entry(effective_txid) + .or_insert(effective_height); + } + } + } + + // Unspent UTXOs → new_utxos (the balance source). + // Pre-read `length()` gates on `outpoint` and `script` before materializing + // the Vec so tampered oversize values are caught before heap allocation. + // Uses `prepare + query + while let` (not `query_map`) so the typed + // `BlobTooLarge` error can be returned from the loop body directly. + { + let mut stmt = conn.prepare( + "SELECT length(outpoint), outpoint, value, length(script), script \ + FROM core_utxos WHERE wallet_id = ?1 AND spent = 0", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + // col 0: length(outpoint) — gate before materializing + blob::check_size(row.get::<_, i64>(0)?)?; + let op_bytes: Vec = row.get(1)?; + let value: i64 = row.get(2)?; + // col 3: length(script) — gate before materializing + blob::check_size(row.get::<_, i64>(3)?)?; + let script_bytes: Vec = row.get(4)?; + let outpoint = blob::decode_outpoint(&op_bytes)?; + let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; + let height = transaction_heights.get(&outpoint.txid).copied().flatten(); + let script = dashcore::ScriptBuf::from_bytes(script_bytes); + if let Some(owner) = owning_account_for_script(conn, wallet_id, script.as_bytes())? { + utxo_accounts.insert(outpoint, owner); + } + // TODO(unspent-script-recovery-tolerance): Recovery tolerance + // for an undecodable unspent script is deliberately deferred. + // This stays fail-hard because it is the balance source — + // tolerating a failed decode drops a UTXO and silently + // under-reports the balance. The cost of deferring is severe + // and measured: `load()` builds every healthy wallet in the + // file, then discards all of it when a later wallet hits this + // line, because the per-wallet loop returns `Ok(state)` only + // after it completes. Under Recovery — the mode whose purpose + // is to hand back whatever it can — one bad row still costs + // the user every wallet in the file. + let address = dashcore::Address::from_script(&script, network)?; + let utxo = Utxo { + outpoint, + txout: dashcore::TxOut { + value, + script_pubkey: script, + }, + address, + height: height.unwrap_or(0), + is_coinbase: false, + is_confirmed: height.is_some(), + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + cs.new_utxos.push(utxo); + } + } + + { + // Same pre-read length gate as `record_blob` above. `txid` is a raw + // 32-byte hash, so its width is gated fixed before materializing — + // an oversize column raises `BlobTooLarge` ahead of the `Vec` alloc + // rather than materializing then failing in `Txid::from_slice`. + let mut stmt = conn.prepare( + "SELECT length(txid), txid, length(islock_blob), islock_blob \ + FROM core_instant_locks WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + use dashcore::hashes::Hash; + blob::check_fixed_width(row.get::<_, i64>(0)?, 32, "core_instant_locks.txid")?; + let txid_bytes: Vec = row.get(1)?; + blob::check_size(row.get::<_, i64>(2)?)?; + let blob_bytes: Vec = row.get(3)?; + let txid = dashcore::Txid::from_slice(&txid_bytes)?; + let islock: dashcore::ephemerealdata::instant_lock::InstantLock = + blob::decode(&blob_bytes)?; + cs.instant_locks_for_non_final_records.insert(txid, islock); + } + } + + // Sync watermarks + persisted chain lock. Read `length()` first so an + // oversize chain-lock blob is rejected before the Vec is allocated. + { + let mut stmt = conn.prepare( + "SELECT last_processed_height, synced_height, \ + length(last_applied_chain_lock), last_applied_chain_lock \ + FROM core_sync_state WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + if let Some(row) = rows.next()? { + let lp: Option = row.get(0)?; + let sy: Option = row.get(1)?; + // Gate before materializing: NULL length means no chain lock. + if let Some(n) = row.get::<_, Option>(2)? { + blob::check_size(n)?; + } + let cl_bytes: Option> = row.get(3)?; + // Fail-hard on an out-of-range watermark (corruption never skipped). + cs.last_processed_height = + height_column_u32("core_sync_state.last_processed_height", lp)?; + cs.synced_height = height_column_u32("core_sync_state.synced_height", sy)?; + // Policy decides: strict aborts on a corrupt chain-lock blob, + // recovery leaves the field None for the next ChainLock event. + if let Some(bytes) = cl_bytes { + cs.last_applied_chain_lock = decode_chain_lock(&bytes, ctx)?; + } + } + } + + Ok((cs, utxo_accounts)) +} + +/// Every address that has ever held a `core_utxos` row for this wallet — +/// spent **and** unspent — deduplicated, each paired with its resolved +/// owning account. The rehydration address-reuse guard: an address whose +/// UTXO was since spent must still be marked used so it's never handed back +/// out as a fresh receive address. +/// +/// `core_utxos` carries no unambiguous account attribution, so ownership is +/// resolved per script via [`owning_account_for_script`]; the result is +/// `None` when the script matches no pool row (the caller then routes to the +/// first funds account). `network` turns each persisted `script` back into an +/// [`Address`](dashcore::Address). An invalid script is fatal under Strict; +/// Recovery counts and skips that address while continuing with the rest. +/// This compatibility entry point uses Strict; rehydration calls +/// [`load_used_addresses_with_ctx`] with its policy context. +pub fn load_used_addresses( + conn: &Connection, + wallet_id: &WalletId, + network: dashcore::Network, +) -> Result)>, WalletStorageError> { + load_used_addresses_with_ctx(conn, wallet_id, network, &LoadCtx::strict()) +} + +/// [`load_used_addresses`] under an explicit load policy. +pub fn load_used_addresses_with_ctx( + conn: &Connection, + wallet_id: &WalletId, + network: dashcore::Network, + ctx: &LoadCtx, +) -> Result)>, WalletStorageError> { + // Gate the largest stored `script` with a cheap aggregate BEFORE the + // `DISTINCT ... ORDER BY script` read materializes or sorts any blob, so a + // corrupt/oversize column raises a typed `BlobTooLarge` (the crate's 16 MiB + // cap) rather than SQLite's own `TooBig` mid-sort, and never OOMs the host. + // `core_utxos` has no `(wallet_id, script)` index, so the read would sort + // the blob; the aggregate gate fires first regardless of query plan. + blob::check_max_column_len( + conn, + "SELECT MAX(length(script)) FROM core_utxos WHERE wallet_id = ?1", + wallet_id, + )?; + // Materialize the scripts before resolving ownership: `owning_account_for_script` + // prepares its own statement on `conn`, so the reader statement must be + // finished first. + let scripts: Vec> = { + let mut stmt = conn.prepare( + "SELECT DISTINCT script FROM core_utxos WHERE wallet_id = ?1 AND is_sweep_placeholder = 0 ORDER BY script", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + row.get::<_, Vec>(0) + })?; + rows.collect::>()? + }; + let mut out = Vec::with_capacity(scripts.len()); + for raw in scripts { + let owner = owning_account_for_script(conn, wallet_id, &raw)?; + let address = match blob::decode_script_to_address(raw, network) { + Ok(address) => address, + Err(error) => { + ctx.tolerate(LoadSite::UndecodableAddressScript, error)?; + continue; + } + }; + out.push((address, owner)); + } + Ok(out) +} + /// The wallet's `(last_processed_height, synced_height, chainlock_height)` /// watermark triple as read back from `core_sync_state`. type SyncHeights = (Option, Option, Option); @@ -846,9 +1160,9 @@ fn read_sync_heights( .optional()? .unwrap_or((None, None, None)); Ok(( - sync_height_u32("core_sync_state.last_processed_height", raw.0)?, - sync_height_u32("core_sync_state.synced_height", raw.1)?, - sync_height_u32("core_sync_state.chainlock_height", raw.2)?, + height_column_u32("core_sync_state.last_processed_height", raw.0)?, + height_column_u32("core_sync_state.synced_height", raw.1)?, + height_column_u32("core_sync_state.chainlock_height", raw.2)?, )) } @@ -871,8 +1185,8 @@ fn read_sync_heights( /// funding upsert materialising it, a later block-context sweep stamping /// it, or a release deleting it (see `apply_sweep`). /// -/// One pass, narrowed to `height IS NULL` (only the tombstone insert -/// leaves `height` NULL, so the set is exactly the never-materialised +/// One pass, narrowed to `is_sweep_placeholder = 1` (only the tombstone insert +/// sets the placeholder flag, so the set is exactly the never-materialised /// rows, served by the partial index): held rows whose winner height is /// at or below the boundary are collected. There is no released-leftover /// shape to sweep up — a release deletes a never-materialised row in-line @@ -893,7 +1207,7 @@ fn collect_finalized_tombstones( let boundary = cl.min(sy); let mut stmt = tx.prepare_cached( "DELETE FROM core_utxos \ - WHERE wallet_id = ?1 AND height IS NULL AND spent = 1 \ + WHERE wallet_id = ?1 AND is_sweep_placeholder = 1 AND spent = 1 \ AND winner_mined_height <= ?2", )?; stmt.execute(params![wallet_id.as_slice(), i64::from(boundary)])?; @@ -902,40 +1216,77 @@ fn collect_finalized_tombstones( /// 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( +fn height_column_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, - } - })?)), - } + value + .map(|v| crate::sqlite::util::safe_cast::i64_to_u32(field, v)) + .transpose() } -/// Fetch a single transaction record by txid. Returns `Ok(None)` if -/// absent. +/// Fetch a single transaction record by txid. +/// +/// Returns `Ok(None)` when the row is absent or carries only confirmation +/// height metadata. +/// A height-only row is not synthesized because UTXO height is not attested +/// block context and must not masquerade as a `BlockInfo`. pub fn get_tx_record( conn: &Connection, wallet_id: &WalletId, txid: &dashcore::Txid, + ctx: &LoadCtx, ) -> 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)?)), + // Pre-read `length()` gate before materializing, consistent with the + // bulk load_state path above. + let mut stmt = conn.prepare_cached( + "SELECT height, length(record_blob), record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + let height = height_column_u32("core_transactions.height", row.get::<_, Option>(0)?)?; + let Some(record_blob_len) = row.get::<_, Option>(1)? else { + return Ok(None); + }; + blob::check_size(record_blob_len)?; + let payload: Vec = row.get(2)?; + let record = blob::decode(&payload)?; + drop(rows); + drop(stmt); + if let Err(mismatch) = ensure_transaction_record_matches_columns(txid, height, &record) { + // Captured before `mismatch` moves: only a txid disagreement means + // the row is a different transaction from the one asked for. + let names_another_transaction = record.txid != *txid; + ctx.tolerate(LoadSite::CoreTransactionColumnDrift, mismatch)?; + // Recovery tolerated the drift, which licenses continuing the load — + // not answering a point read with someone else's transaction. Height + // drift keeps the blob authoritative and still serves the record. + if names_another_transaction { + return Ok(None); + } + } + Ok(Some(record)) +} + +/// Diagnose typed txid/height columns that disagree with the authoritative blob. +fn ensure_transaction_record_matches_columns( + typed_txid: &dashcore::Txid, + typed_height: Option, + record: &TransactionRecord, +) -> Result<(), WalletStorageError> { + let blob_height = record.block_info().map(|block_info| block_info.height()); + if record.txid != *typed_txid || blob_height != typed_height { + return Err(WalletStorageError::CoreTransactionEntryMismatch { + typed_txid: typed_txid.to_string(), + blob_txid: record.txid.to_string(), + typed_height, + blob_height, + }); } + Ok(()) } /// Row representing one unspent UTXO. Used by tests that probe the @@ -946,58 +1297,943 @@ 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. Retained for this crate's integration tests. +/// All UTXOs for a wallet that have not been spent yet, bucketed by the +/// account index resolved from `core_address_pool` during the read. #[cfg(any(test, feature = "__test-helpers"))] pub fn list_unspent_utxos( conn: &Connection, wallet_id: &WalletId, ) -> Result>, WalletStorageError> { - let mut stmt = conn.prepare( - "SELECT outpoint, value, script, height, account_index \ + let mut stmt = conn.prepare_cached( + "SELECT outpoint, value, script \ 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)) + Ok((op_bytes, value, script)) })?; let mut by_account: BTreeMap> = BTreeMap::new(); for r in rows { - let (op_bytes, value, script_bytes, height, account_index) = r?; + let (op_bytes, value, script_bytes) = 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 account_index = owning_account_for_script(conn, wallet_id, &script_bytes)? + .map(|owner| owner.account_index) + .unwrap_or(0); let row = UnspentRow { outpoint, value, script: script_bytes, - height, account_index, }; by_account.entry(account_index).or_default().push(row); } Ok(by_account) } + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use dashcore::{BlockHash, OutPoint, PubkeyHash, Transaction, TxOut, Txid}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + + fn transaction_record(txid: Txid, context: TransactionContext) -> TransactionRecord { + let mut record = TransactionRecord::new( + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + context, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 100, + ); + record.txid = txid; + record + } + + fn sample_utxo(txid: Txid, height: u32, is_confirmed: bool) -> Utxo { + let address = dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0x23u8; 20])), + ); + Utxo { + outpoint: OutPoint { txid, vout: 0 }, + txout: TxOut { + value: 150_000, + script_pubkey: address.script_pubkey(), + }, + address, + height, + is_coinbase: false, + is_confirmed, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } + } + + fn sample_chain_lock(height: u32) -> ChainLock { + ChainLock { + block_height: height, + block_hash: BlockHash::from_byte_array([0x11u8; 32]), + signature: [0x22u8; 96].into(), + } + } + + /// A tampered `core_instant_locks.txid` that overflows the blob cap must + /// raise `BlobTooLarge` from the fixed-width gate BEFORE the oversize `Vec` + /// is materialized — not `BlobDecode` after `Txid::from_slice` on a + /// multi-megabyte allocation. + #[test] + fn load_state_rejects_oversize_instant_lock_txid() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let w = [0xABu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + + // Plant a txid one byte past the 16 MiB cap; islock_blob content is + // irrelevant — the txid gate fires before it is read. + let oversize_txid = vec![0u8; crate::SIZE_LIMIT_BYTES + 1]; + conn.execute( + "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, ?3)", + params![&w[..], oversize_txid.as_slice(), &[0u8; 4][..]], + ) + .unwrap(); + + let err = load_state(&conn, &w, dashcore::Network::Testnet, &LoadCtx::strict()) + .expect_err("load_state must reject an oversize instant-lock txid"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge from the pre-materialization gate, got {err:?}" + ); + } + + #[test] + fn load_state_reconciles_utxo_height_from_confirmed_transaction_record() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x42u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let txid = Txid::from_byte_array([0x7Eu8; 32]); + let utxo = sample_utxo(txid, 123, true); + let outpoint = utxo.outpoint; + + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo], + records: vec![transaction_record(txid, TransactionContext::Mempool)], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + let confirmed_height = 321; + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![transaction_record( + txid, + TransactionContext::InChainLockedBlock(BlockInfo::new( + confirmed_height, + BlockHash::from_byte_array([0x34u8; 32]), + 1_735_689_600, + )), + )], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + let loaded = state + .new_utxos + .iter() + .find(|candidate| candidate.outpoint == outpoint) + .expect("matching UTXO must be loaded"); + assert_eq!(loaded.height, confirmed_height); + assert!(loaded.is_confirmed); + } + + #[test] + fn load_state_restores_confirmed_recordless_utxo_height() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x44u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let txid = Txid::from_byte_array([0x80u8; 32]); + let utxo = sample_utxo(txid, 456, true); + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + let loaded = state.new_utxos.first().expect("recordless UTXO must load"); + assert_eq!(loaded.height, 456); + assert!(loaded.is_confirmed); + assert!(state.records.is_empty()); + assert!(get_tx_record(&conn, &wallet_id, &txid, &LoadCtx::strict()) + .unwrap() + .is_none()); + } + + #[test] + fn height_only_placeholder_does_not_regress() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x49u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let txid = Txid::from_byte_array([0x86u8; 32]); + let mut utxo = sample_utxo(txid, 500, true); + for stale in [ + CoreChangeSet { + new_utxos: vec![utxo.clone()], + ..Default::default() + }, + { + utxo.height = 400; + CoreChangeSet { + new_utxos: vec![utxo.clone()], + ..Default::default() + } + }, + { + utxo.height = 0; + utxo.is_confirmed = false; + CoreChangeSet { + new_utxos: vec![utxo], + ..Default::default() + } + }, + ] { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &stale).unwrap(); + tx.commit().unwrap(); + } + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(state.new_utxos[0].height, 500); + assert!(state.new_utxos[0].is_confirmed); + } + + #[test] + fn load_state_treats_height_zero_as_confirmed() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x4Au8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let txid = Txid::from_byte_array([0x87u8; 32]); + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![sample_utxo(txid, 0, true)], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(state.new_utxos[0].height, 0); + assert!(state.new_utxos[0].is_confirmed); + } + + #[test] + fn transaction_record_always_overrides_height_only_placeholder() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x45u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let txid = Txid::from_byte_array([0x81u8; 32]); + let mut utxo = sample_utxo(txid, 456, true); + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo.clone()], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![transaction_record(txid, TransactionContext::Mempool)], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + utxo.height = 789; + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(state.new_utxos[0].height, 0); + assert!(!state.new_utxos[0].is_confirmed); + assert_eq!(state.records.len(), 1); + + let confirmed_height = 900; + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![transaction_record( + txid, + TransactionContext::InChainLockedBlock(BlockInfo::new( + confirmed_height, + BlockHash::from_byte_array([0x35u8; 32]), + 1_735_689_700, + )), + )], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(state.new_utxos[0].height, confirmed_height); + assert!(state.new_utxos[0].is_confirmed); + } + + #[test] + fn load_state_defaults_utxo_without_transaction_record_to_unconfirmed() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x43u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let txid = Txid::from_byte_array([0x7Fu8; 32]); + let address = dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0x24u8; 20])), + ); + let outpoint = OutPoint { txid, vout: 0 }; + let utxo = Utxo::new( + outpoint, + TxOut { + value: 175_000, + script_pubkey: address.script_pubkey(), + }, + address, + 777, + false, + ); + + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + let loaded = state + .new_utxos + .iter() + .find(|candidate| candidate.outpoint == outpoint) + .expect("matching UTXO must be loaded"); + assert_eq!(loaded.height, 0); + assert!(!loaded.is_confirmed); + } + + #[test] + fn load_state_tolerates_transaction_blob_txid_drift_in_recovery_without_repairing() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x46u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let blob_txid = Txid::from_byte_array([0x82u8; 32]); + let typed_txid = Txid::from_byte_array([0x83u8; 32]); + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![transaction_record(blob_txid, TransactionContext::Mempool)], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + conn.execute( + "UPDATE core_transactions SET txid = ?1 WHERE wallet_id = ?2", + params![AsRef::<[u8]>::as_ref(&typed_txid), wallet_id.as_slice()], + ) + .unwrap(); + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::recovery(), + ) + .expect("recovery mode must reconstruct from the authoritative blob"); + assert_eq!(state.records[0].txid, blob_txid); + let on_disk: Vec = conn + .query_row( + "SELECT txid FROM core_transactions WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + on_disk, + AsRef::<[u8]>::as_ref(&typed_txid), + "a read must never rewrite the row it read" + ); + } + + #[test] + fn load_state_blob_height_wins_over_drifted_typed_column_in_either_scan_order() { + for (case, typed_byte) in [0x10, 0xF0].into_iter().enumerate() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x50 + case as u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let blob_txid = Txid::from_byte_array([0x80; 32]); + let typed_txid = Txid::from_byte_array([typed_byte; 32]); + let confirmed_utxo = sample_utxo(blob_txid, 500, true); + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![confirmed_utxo.clone()], + records: vec![transaction_record( + blob_txid, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 500, + BlockHash::from_byte_array([0x38; 32]), + 1_735_690_000, + )), + )], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + conn.execute( + "UPDATE core_transactions SET txid = ?1 WHERE wallet_id = ?2", + params![AsRef::<[u8]>::as_ref(&typed_txid), wallet_id.as_slice()], + ) + .unwrap(); + + let mut stale_utxo = confirmed_utxo; + stale_utxo.height = 100; + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![stale_utxo], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::recovery(), + ) + .expect("recovery mode must still reconstruct blob-authoritative state"); + let loaded = state.new_utxos.first().expect("UTXO must load"); + assert_eq!(loaded.height, 500, "failed scan-order case {case}"); + assert!(loaded.is_confirmed); + } + } + + #[test] + fn load_state_tolerates_transaction_blob_height_drift_in_recovery_without_repairing() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x47u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let txid = Txid::from_byte_array([0x84u8; 32]); + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![transaction_record( + txid, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 500, + BlockHash::from_byte_array([0x36u8; 32]), + 1_735_689_800, + )), + )], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + conn.execute( + "UPDATE core_transactions SET height = 501 WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + ) + .unwrap(); + + let (state, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::recovery(), + ) + .expect("recovery mode must reconstruct from the authoritative blob"); + assert_eq!(state.records[0].height(), Some(500)); + let on_disk: Option = conn + .query_row( + "SELECT height FROM core_transactions WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + on_disk, + Some(501), + "a read must never rewrite the row it read" + ); + } + + /// A txid-drifted row names a DIFFERENT transaction than the one asked + /// for, so recovery mode must decline to answer rather than hand back + /// the wrong record. Contrast the height-drift sibling below, where the + /// blob stays authoritative and the record is still served. + #[test] + fn get_tx_record_declines_a_txid_drifted_row_in_recovery_without_repairing() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x4Bu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let blob_txid = Txid::from_byte_array([0x88u8; 32]); + let typed_txid = Txid::from_byte_array([0x89u8; 32]); + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![transaction_record(blob_txid, TransactionContext::Mempool)], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + conn.execute( + "UPDATE core_transactions SET txid = ?1 WHERE wallet_id = ?2", + params![AsRef::<[u8]>::as_ref(&typed_txid), wallet_id.as_slice()], + ) + .unwrap(); + + assert!( + get_tx_record(&conn, &wallet_id, &typed_txid, &LoadCtx::recovery()) + .expect("recovery mode tolerates the drift instead of erroring") + .is_none(), + "the row's blob names a different transaction than the one asked \ + for; recovery must decline rather than serve the wrong record" + ); + // The row was NOT repaired, so the blob txid still matches no row. + assert!( + get_tx_record(&conn, &wallet_id, &blob_txid, &LoadCtx::recovery()) + .unwrap() + .is_none(), + "a read must never rewrite the row it read" + ); + // Declining is not deleting: the drifted row is still on disk under + // its typed txid, for an operator or a later repair pass to find. + let still_present: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(&typed_txid)], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(still_present, 1, "a read must never delete the row it read"); + } + + #[test] + fn get_tx_record_tolerates_blob_height_drift_in_recovery_without_repairing() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x4Cu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let txid = Txid::from_byte_array([0x8Au8; 32]); + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![transaction_record( + txid, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 600, + BlockHash::from_byte_array([0x37u8; 32]), + 1_735_689_900, + )), + )], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + conn.execute( + "UPDATE core_transactions SET height = 601 WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + ) + .unwrap(); + + let record = get_tx_record(&conn, &wallet_id, &txid, &LoadCtx::recovery()) + .expect("recovery mode must still serve the point read") + .expect("blob-bearing row must return its record"); + assert_eq!(record.height(), Some(600)); + let on_disk: Option = conn + .query_row( + "SELECT height FROM core_transactions WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + on_disk, + Some(601), + "a read must never rewrite the row it read" + ); + } + + /// `load_used_addresses` (the address-reuse-guard rehydration path called + /// from `persister.rs`) must surface `AddressDecode` — carrying the + /// upstream `dashcore::address::Error` — when a stored `core_utxos.script` + /// parses as bytes but not as an address, not the context-free `BlobDecode`. + #[test] + fn load_used_addresses_wraps_address_error_as_address_decode() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let w = [0x99u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + // A bare OP_RETURN script is well-formed bytes but not any address + // type, so `Address::from_script` returns `UnrecognizedScript`. + let bad_script = [0x6au8]; + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![&w[..], &[0u8; 36][..], &bad_script[..]], + ) + .unwrap(); + + let err = + load_used_addresses_with_ctx(&conn, &w, dashcore::Network::Testnet, &LoadCtx::strict()) + .expect_err("an unparseable script must be a hard error"); + assert!( + matches!(err, WalletStorageError::AddressDecode { .. }), + "expected AddressDecode carrying the upstream error, got {err:?}" + ); + } + + /// An empty `script` must be refused by the WRITER, not discovered by + /// the reader. `load()` turns every stored script back into an address, + /// so one such row rejects the load of the entire database file — the + /// shape migration V015 had to purge. `execute_upsert_utxo` is the only + /// writer of `core_utxos.script`, so guarding it closes the producer. + #[test] + fn apply_refuses_an_empty_script_on_a_new_utxo() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x5Bu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let mut utxo = sample_utxo(Txid::from_byte_array([0x5Bu8; 32]), 10, true); + utxo.txout.script_pubkey = dashcore::ScriptBuf::from_bytes(Vec::new()); + let outpoint = utxo.outpoint; + let cs = CoreChangeSet { + new_utxos: vec![utxo], + ..Default::default() + }; + + let tx = conn.transaction().unwrap(); + let err = apply(&tx, &wallet_id, &cs).expect_err("an empty script must be refused"); + match err { + WalletStorageError::EmptyUtxoScript { outpoint: got } => { + assert_eq!(got, outpoint, "the error must name the offending outpoint"); + } + other => panic!("expected EmptyUtxoScript, got {other:?}"), + } + let rows: i64 = tx + .query_row("SELECT COUNT(*) FROM core_utxos", [], |row| row.get(0)) + .unwrap(); + assert_eq!(rows, 0, "the guard must refuse before binding the row"); + } + + /// The spend path synthesises a `spent = 1` row when the UTXO has no + /// existing row, which is exactly the shape V015 had to delete. It runs + /// through the same writer, so it must be refused on the same terms. + #[test] + fn apply_refuses_an_empty_script_on_a_synthetic_spent_row() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x5Cu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let mut utxo = sample_utxo(Txid::from_byte_array([0x5Cu8; 32]), 10, true); + utxo.txout.script_pubkey = dashcore::ScriptBuf::from_bytes(Vec::new()); + let cs = CoreChangeSet { + spent_utxos: vec![utxo], + ..Default::default() + }; + + let tx = conn.transaction().unwrap(); + let err = apply(&tx, &wallet_id, &cs).expect_err("an empty script must be refused"); + assert!( + matches!(err, WalletStorageError::EmptyUtxoScript { .. }), + "the synthetic spent-row insert must be guarded too, got {err:?}" + ); + let rows: i64 = tx + .query_row("SELECT COUNT(*) FROM core_utxos", [], |row| row.get(0)) + .unwrap(); + assert_eq!(rows, 0, "the guard must refuse before binding the row"); + } + + /// Marking an EXISTING row spent never rewrites `script`, so a healthy + /// row must still be spendable — the guard must not turn a legitimate + /// spend into a write failure. + #[test] + fn apply_still_marks_an_existing_utxo_spent() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0x5Du8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let utxo = sample_utxo(Txid::from_byte_array([0x5Du8; 32]), 10, true); + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![utxo.clone()], + ..Default::default() + }, + ) + .expect("a well-formed script must still be accepted"); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + spent_utxos: vec![utxo], + ..Default::default() + }, + ) + .expect("spending an existing row must still be accepted"); + let spent: bool = tx + .query_row("SELECT spent FROM core_utxos", [], |row| row.get(0)) + .unwrap(); + assert!(spent, "the existing row must be marked spent"); + } + + #[test] + fn chain_lock_height_rejects_trailing_bytes() { + let bytes = encode_chain_lock(&sample_chain_lock(100_000)).expect("encode"); + assert_eq!(chain_lock_height(&bytes), Some(100_000)); + + // A corrupt blob (valid prefix + trailing garbage) must not yield a + // height, else it stays stuck atop later valid lower-height updates. + let mut corrupt = bytes.clone(); + corrupt.extend_from_slice(&[0xFFu8; 4]); + assert_eq!(chain_lock_height(&corrupt), None); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs index b6caa52fb11..e9dd5cdb6ae 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -1,16 +1,19 @@ //! `dashpay_profiles` + `dashpay_payments_overlay` writers. //! +//! # Write-only indexed overlay (NOT a rehydration source) +//! +//! These tables are honored on write but `load()` does NOT read them back: +//! DashPay state is rehydrated from the identities `entry_blob`, which is the +//! authoritative load source. They exist for future per-profile/per-payment +//! indexed queries. Round-trip pinned by +//! `tests/sqlite_dashpay_overlay_contract.rs`. +//! //! # Precondition //! -//! Every `identity_id` in the supplied profile / payment maps MUST -//! already exist in the `identities` table and belong to the flush's -//! `wallet_id`. The writer relies on the -//! `identities(identity_id, wallet_id)` row produced by -//! [`super::identities::apply`] (in the same transaction or earlier) -//! for parenting; the FK to `identities(identity_id)` enforces the -//! existence half, but not the wallet match. The precondition check -//! below runs in every build and propagates -//! [`WalletStorageError::WalletIdMismatch`] on a mis-attributed caller. +//! Every `identity_id` MUST already exist in `identities` and belong to the +//! flush's `wallet_id`. The FK enforces existence; the wallet match is checked +//! here and propagates [`WalletStorageError::WalletIdMismatch`] on a +//! mis-attributed caller. use std::collections::BTreeMap; @@ -22,23 +25,20 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; +use crate::sqlite::schema::blob::impl_persistable_blob; + +// PUBLIC material only: DashPay overlay types reaching `_blob` columns. +impl_persistable_blob!(DashPayProfile, PaymentEntry); -/// 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 symmetry -/// with the persister's `write_changeset_in_one_tx` dispatch table, -/// and feeds the precondition check; it does not feed any column. +/// Both tables are keyed by identity only; their FK to +/// `identities(identity_id)` cascades via the `wallets → identities` chain. +/// `wallet_id` feeds the precondition check only — no column. pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, profiles: Option<&BTreeMap>>, payments: Option<&BTreeMap>>, ) -> Result<(), WalletStorageError> { - // Precondition: every identity_id we touch must already belong to - // the flush-scope wallet (or to no wallet if scope is the - // sentinel). Cheap SELECT inside the same tx, run in every build. let touched: std::collections::BTreeSet = profiles .iter() .flat_map(|m| m.keys().copied()) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs index 7175ba5a09f..81ffaa16535 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs @@ -343,7 +343,7 @@ mod tests { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![&wallet_id[..]], ) .unwrap(); @@ -393,7 +393,7 @@ mod tests { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![&wallet_id[..]], ) .unwrap(); @@ -435,7 +435,7 @@ mod tests { crate::sqlite::migrations::run(&mut conn).unwrap(); for w in [&wallet_id, &other_wallet_id] { conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![&w[..]], ) .unwrap(); @@ -512,7 +512,7 @@ mod tests { // A wallet holding no such row at all answers None. let empty_wallet_id: WalletId = [0x77; 32]; conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![&empty_wallet_id[..]], ) .unwrap(); @@ -560,7 +560,7 @@ mod tests { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![&wallet_id[..]], ) .unwrap(); @@ -634,7 +634,7 @@ mod tests { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![&wallet_id[..]], ) .unwrap(); @@ -688,7 +688,7 @@ mod tests { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![&wallet_id[..]], ) .unwrap(); @@ -756,7 +756,7 @@ mod tests { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![&wallet_id[..]], ) .unwrap(); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 72b22435f27..0ff7f1fe848 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -1,60 +1,68 @@ //! `identities` table writer. -use rusqlite::{params, Transaction}; +use std::collections::{BTreeMap, HashMap, HashSet}; -use platform_wallet::changeset::IdentityChangeSet; -use platform_wallet::wallet::platform_wallet::WalletId; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; +use platform_wallet::{ContactChangeSet, IdentityKeysChangeSet, ManagedIdentity}; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; -// Imports used only by the test-gated readers below. -#[cfg(any(test, feature = "__test-helpers"))] -use {platform_wallet::changeset::IdentityEntry, rusqlite::Connection}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::{ + changeset::{IdentityChangeSet, IdentityEntry}, + IdentityManagerStartState, +}; +use super::wallet_id_to_param; use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite, SiteCoords}; use crate::sqlite::schema::blob; +use crate::sqlite::schema::blob::impl_persistable_blob; + +// PUBLIC material only: identity snapshot reaching the `entry_blob` column. +impl_persistable_blob!(IdentityEntry); pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, cs: &IdentityChangeSet, ) -> Result<(), WalletStorageError> { + // `store` checks the merged buffer before a changeset joins it; this + // checks what actually reaches disk. Disk state can still move under + // a buffered changeset — a sibling persister on the same file — and + // `delete_wallet`'s pre-flush never passes through `store` at all. + check_index_conflicts(tx, wallet_id, cs)?; if !cs.identities.is_empty() { - // PK is `identity_id` alone; `wallet_id` is nullable and links - // the identity to its parent wallet for cascade. The all-zero - // wallet id is treated as "no parent wallet known" and stored - // as NULL so the FK to `wallet_metadata` doesn't activate. - // - // COALESCE order — `COALESCE(identities.wallet_id, - // excluded.wallet_id)` — preserves an already-parented row's - // wallet_id on re-upsert; the excluded value only fills when - // the on-disk row is still NULL. This is the orphan → parented - // promotion path; the reverse (mismatched re-parent) is caught - // by the per-entry cross-check below. + // COALESCE keeps an already-parented row's wallet_id on re-upsert + // (excluded fills only when on-disk is NULL): the orphan → parented + // promotion path. The all-zero sentinel stores NULL (no parent). let scope_is_sentinel = wallet_id.iter().all(|b| *b == 0); + // The DO UPDATE WHERE keeps a wallet-B flush from overwriting wallet + // A's row: it fires only when the on-disk row is unowned (orphan → + // parented promotion) or already owned by the incoming scope. A + // cross-wallet write becomes a no-op (SQLite skips a false-WHERE + // upsert without erroring), preserving the resident blob, index, and + // tombstone. `IS` is the NULL-safe match for the nullable column. let mut stmt = tx.prepare_cached( - "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ VALUES (?1, ?2, ?3, ?4, 0) \ ON CONFLICT(identity_id) DO UPDATE SET \ wallet_id = COALESCE(identities.wallet_id, excluded.wallet_id), \ - wallet_index = excluded.wallet_index, \ + identity_index = excluded.identity_index, \ entry_blob = excluded.entry_blob, \ - tombstoned = 0", + tombstoned = 0 \ + WHERE identities.wallet_id IS NULL OR identities.wallet_id IS excluded.wallet_id", )?; let wallet_id_param = wallet_id_to_param(wallet_id); for (id, entry) in &cs.identities { - // The map key is bound into the `identity_id` column while - // `entry` is what the serialized blob carries; a disagreement - // would persist a row whose typed id names a different - // identity than its blob. Reject before encoding so the two - // representations can never diverge on disk. + // Typed id column and blob must name the same identity; reject + // before encoding so the two can never diverge on disk. if entry.id != *id { return Err(WalletStorageError::IdentityEntryIdMismatch); } - // Cross-check: the entry's own wallet_id (when set) must - // agree with the flush scope so the typed columns and the - // serialized blob describe the same parenting. Sentinel - // scope ("no parent wallet known") requires the entry's - // wallet_id to also be `None` — otherwise a real wallet's - // identity would be written under the orphan slot. + // The entry's wallet_id (when set) must match the flush scope; + // sentinel scope requires it to be `None`, else a real wallet's + // identity would land in the orphan slot. if let Some(entry_wallet_id) = entry.wallet_id { if scope_is_sentinel { return Err(WalletStorageError::WalletIdMismatch { @@ -77,26 +85,158 @@ pub fn apply( payload, ])?; } + // Carry the promoted identities' NULL-scoped keys into the same + // scope, inside the promotion's own transaction. A key left + // NULL-scoped under a now-owned identity is the state the + // `identity_keys_null_scope_*` triggers forbid, and it drops out of + // the owner's scoped read. The `EXISTS` clause is what makes this + // safe to run for every flushed id: it fires only where the identity + // row really is owned by this scope, so a cross-wallet upsert that + // the `DO UPDATE WHERE` turned into a no-op moves no keys either. + if !scope_is_sentinel { + let mut rescope = tx.prepare_cached( + "UPDATE identity_keys SET wallet_id = ?1 \ + WHERE identity_id = ?2 AND wallet_id IS NULL \ + AND EXISTS (SELECT 1 FROM identities i \ + WHERE i.identity_id = ?2 AND i.wallet_id IS ?1)", + )?; + for id in cs.identities.keys() { + rescope.execute(params![wallet_id_param, id.as_slice()])?; + } + } } if !cs.removed.is_empty() { - let mut stmt = - tx.prepare_cached("UPDATE identities SET tombstoned = 1 WHERE identity_id = ?1")?; + // Scope the tombstone to the flush wallet (NULL-safe `IS`) so wallet + // A's `removed` set can't tombstone wallet B's identity; the sentinel + // scope maps to NULL and tombstones only orphan rows. + let wallet_id_param = wallet_id_to_param(wallet_id); + let mut stmt = tx.prepare_cached( + "UPDATE identities SET tombstoned = 1 WHERE identity_id = ?1 AND wallet_id IS ?2", + )?; for id in &cs.removed { - stmt.execute(params![id.as_slice()])?; + stmt.execute(params![id.as_slice(), wallet_id_param])?; } } Ok(()) } -/// Map the caller-supplied `WalletId` (32 bytes) to the nullable -/// `identities.wallet_id` column: the all-zero id is treated as "no -/// parent wallet" and stored as NULL so the FK doesn't activate. -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()) +/// Refuse a changeset that would put two identities in one wallet's +/// derivation slot, or hand a wallet-less identity a slot at all. +/// +/// `identity_index` is an HD derivation-path component, so +/// `(wallet_id, identity_index)` names exactly one identity. A duplicate +/// that reaches disk leaves the displaced identity's keys and contacts +/// without an owner, which the next `load()` reports as fatal for the +/// whole wallet — so the write is rejected while it can still be +/// attributed to the caller that made it. +/// +/// Occupancy is keyed on the FLUSH SCOPE, not on the incoming row's +/// stored `wallet_id`: [`apply`]'s upsert promotes a NULL `wallet_id` +/// into the flush scope, so the scope is the slot the write actually +/// lands in. Ids in `cs.removed` hold no slot — [`apply`] inserts before +/// it tombstones, so "tombstone A@N + insert B@N" in one changeset has a +/// legal final state. Tombstoned rows are likewise transparent: the +/// tombstone `UPDATE` leaves `identity_index` populated, and counting +/// those would refuse legitimate slot reuse. +/// +/// The judgement is on the state the changeset ENDS in, never the one it +/// starts from: an on-disk occupant that `cs` itself rewrites to another +/// index — or to none at all — has vacated the slot as surely as a +/// tombstoned one, so "A moves to 2, B takes 1" and a two-way swap are +/// both legal. `identities` carries no `(wallet_id, identity_index)` +/// UNIQUE index, so [`apply`]'s row-at-a-time upserts pass straight +/// through the transient double-claim a swap goes through. +/// +/// A pre-existing on-disk duplicate (written before this check existed) +/// makes both of its slot-mates unwritable here. Refusing to extend the +/// contradiction is the point: a strict load rejects that wallet outright +/// and a `Recovery` load counts the collision and drops one identity from +/// the slot, so the duplicate costs the user an identity either way. +/// +/// Scope-keying also covers the promotion case (an existing NULL-parented +/// row being pulled into a wallet that already fills the slot) at no +/// extra cost. That case is defensive only: no production write creates a +/// `wallet_id IS NULL` `identities` row, since the flush scope is always +/// the persister's bound wallet id. +/// +/// # Errors +/// +/// - [`WalletStorageError::WalletlessIdentityIndex`] — sentinel scope +/// (a NULL `wallet_id` row) combined with an index. +/// - [`WalletStorageError::IdentityIndexConflict`] — the slot is held by +/// a different live identity, on disk or elsewhere in `cs`. +pub(crate) fn check_index_conflicts( + conn: &Connection, + wallet_id: &WalletId, + cs: &IdentityChangeSet, +) -> Result<(), WalletStorageError> { + if cs.identities.is_empty() { + return Ok(()); + } + let scope_is_sentinel = wallet_id.iter().all(|b| *b == 0); + let wallet_id_param = wallet_id_to_param(wallet_id); + let mut stmt = conn.prepare_cached( + "SELECT identity_id FROM identities \ + WHERE wallet_id IS ?1 AND identity_index = ?2 AND tombstoned = 0 \ + AND identity_id != ?3", + )?; + let mut claimed: BTreeMap = BTreeMap::new(); + for (id, entry) in &cs.identities { + // A removed id is tombstoned by the end of the same `apply`, so + // whatever it claims here it does not keep. + if cs.removed.contains(id) { + continue; + } + let Some(index) = entry.identity_index else { + continue; + }; + if scope_is_sentinel { + return Err(WalletStorageError::WalletlessIdentityIndex { + identity_id: id.to_buffer(), + identity_index: index, + }); + } + if let Some(other) = claimed.insert(index, *id) { + return Err(WalletStorageError::IdentityIndexConflict { + wallet_id: *wallet_id, + identity_index: index, + existing: other.to_buffer(), + incoming: id.to_buffer(), + }); + } + let occupant: Option> = stmt + .query_row( + params![wallet_id_param, i64::from(index), id.as_slice()], + |row| row.get(0), + ) + .optional()?; + let Some(occupant) = occupant else { continue }; + let occupant: [u8; 32] = occupant.try_into().map_err(|_| { + WalletStorageError::blob_decode("identities.identity_id is not 32 bytes") + })?; + let occupant_id = Identifier::from(occupant); + if cs.removed.contains(&occupant_id) { + continue; + } + // The occupant is rewritten by this same changeset, somewhere + // other than here: `apply` moves it out of this slot (to another + // index, or to none at all) in the same transaction, so by the + // time the changeset lands it holds no claim on `index`. + if cs + .identities + .get(&occupant_id) + .is_some_and(|entry| entry.identity_index != Some(index)) + { + continue; + } + return Err(WalletStorageError::IdentityIndexConflict { + wallet_id: *wallet_id, + identity_index: index, + existing: occupant, + incoming: id.to_buffer(), + }); } + Ok(()) } /// Decode a single `identities` row into `(entry, tombstoned)`. @@ -111,52 +251,85 @@ pub fn fetch( wallet_id: &WalletId, identity_id: &[u8; 32], ) -> Result, WalletStorageError> { - use rusqlite::OptionalExtension; - // Scope the lookup to the caller's wallet so a peer wallet that - // happens to share the identity-id row can never leak through. - // The sentinel WalletId (all zeros) matches orphan rows (NULL - // wallet_id); a real WalletId matches only that wallet's rows. - // `IS` is NULL-safe equality so the NULL branch works uniformly. + // Scope to the caller's wallet (NULL-safe `IS`) so a peer wallet sharing + // the identity-id row can't leak through; sentinel matches orphan rows. let wallet_id_param = wallet_id_to_param(wallet_id); - let row: Option<(Vec, i64)> = conn - .query_row( - "SELECT entry_blob, tombstoned FROM identities \ - WHERE identity_id = ?1 AND wallet_id IS ?2", - params![&identity_id[..], wallet_id_param], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - match row { + let mut stmt = conn.prepare( + "SELECT length(entry_blob), entry_blob, tombstoned FROM identities \ + WHERE identity_id = ?1 AND wallet_id IS ?2", + )?; + let mut rows = stmt.query(params![&identity_id[..], wallet_id_param])?; + match rows.next()? { None => Ok(None), - Some((payload, tombstoned)) => Ok(Some((blob::decode(&payload)?, tombstoned != 0))), + Some(row) => { + blob::check_size(row.get::<_, i64>(0)?)?; + let payload: Vec = row.get(1)?; + let tombstoned: i64 = row.get(2)?; + Ok(Some((blob::decode(&payload)?, tombstoned != 0))) + } } } -/// 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 +/// Build an [`IdentityManagerStartState`](platform_wallet::changeset::IdentityManagerStartState) +/// for one wallet. Tombstoned rows are skipped; a row that fails to decode is +/// a hard error (corruption is never silently dropped). Rows with +/// `identity_index = Some(_)` bucket into `wallet_identities`, `None` into /// `out_of_wallet_identities`. -/// -/// Retained for this crate's integration tests until the -/// `Wallet::from_persisted` rehydration path consumes it in `load()`. +/// Strict-policy [`load_state_with_ctx`], for the tests that read identity +/// state directly rather than through `load_prekeyed`. #[cfg(any(test, feature = "__test-helpers"))] pub fn load_state( conn: &Connection, wallet_id: &WalletId, +) -> Result { + // `LoadCtx::strict()` is cfg-gated to test/helper builds; the policy + // constructor is not, so the plain library build keeps compiling. + load_state_with_ctx( + conn, + wallet_id, + &LoadCtx::new(crate::sqlite::config::LoadPolicy::Strict), + ) +} + +/// [`load_state`] under an explicit load policy. +/// +/// Only the duplicate-slot case consults `ctx`: a second live row claiming +/// an `identity_index` this wallet has already filled is fatal under +/// `Strict` and a counted, logged drop under `Recovery`. Rows are read by +/// `identity_id` ascending, so the lexicographically higher id wins a +/// collision. +/// +/// # The typed-column cross-checks are DELIBERATELY fatal in both policies +/// +/// An `identities` row carries the identity's credit balance, so skipping one +/// would hand back a wallet whose reported credits are quietly too low — the +/// same harm that keeps the unspent-script decode fail-hard in `core_state`, +/// and the reason `platform_addresses` degrades by wallet rather than by row. +/// Sitting outside `ctx` does NOT put these sites outside the load policy: +/// they reach `load()`'s per-wallet isolation boundary, which drops the whole +/// wallet under `Recovery` and names it in `LoadDegradation::wallets_degraded`. +/// A visibly missing wallet is a fact a caller can act on; a wallet with +/// silently missing credits is not. +/// +/// Do not convert them to `ctx.tolerate`. Beyond the balance, a skipped +/// identity orphans its keys and contacts, and the orphan is fatal in both +/// policies two functions later in `merge_contacts_and_keys` — so per-row +/// tolerance here would relocate a failure rather than remove one. +pub fn load_state_with_ctx( + conn: &Connection, + wallet_id: &WalletId, + ctx: &LoadCtx, ) -> Result { use platform_wallet::changeset::IdentityManagerStartState; - // `identities.wallet_id` is nullable; this load path 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). + // Per-wallet loader. NULL-safe `IS` so the scope reads exactly the way + // it writes: a real wallet id behaves identically to `=` and never sees + // unowned rows, while the all-zero sentinel maps to NULL and reads the + // unowned bucket. A plain `=` could not express the second case at all. + let wallet_id_param = wallet_id_to_param(wallet_id); let mut stmt = conn.prepare( - "SELECT identity_id, entry_blob, tombstoned FROM identities WHERE wallet_id = ?1", + "SELECT identity_id, length(entry_blob), entry_blob, tombstoned, identity_index \ + FROM identities WHERE wallet_id IS ?1 ORDER BY identity_id", )?; // The ignored-senders TABLE is the authoritative ignore record (every // ignore/un-ignore maintains it transactionally); the `entry_blob`'s @@ -164,24 +337,79 @@ pub fn load_state( let mut ignored_by_owner = crate::sqlite::schema::contacts::load_ignored_senders(conn, wallet_id)?; let mut state = IdentityManagerStartState::default(); - let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut rows = stmt.query(params![wallet_id_param])?; 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)?; + let identity_id_bytes: Vec = row.get(0)?; + blob::check_size(row.get::<_, i64>(1)?)?; + let payload: Vec = row.get(2)?; + let tombstoned: i64 = row.get(3)?; + let typed_identity_index: Option = row.get(4)?; if tombstoned != 0 { continue; } let entry: IdentityEntry = blob::decode(&payload)?; + // Cross-check the decoded blob against the typed columns it was + // selected by (mirrors the accounts / identity_keys readers): the + // blob must name the same identity, and its own wallet_id (when set) + // must match the wallet scope, else the row is corrupt / mis-filed. + let typed_id = super::id32("identities.identity_id", &identity_id_bytes)?; + if entry.id != dpp::prelude::Identifier::from(typed_id) { + return Err(WalletStorageError::IdentityEntryIdMismatch); + } + if let Some(entry_wallet_id) = entry.wallet_id { + if entry_wallet_id != *wallet_id { + return Err(WalletStorageError::WalletIdMismatch { + expected: *wallet_id, + found: entry_wallet_id, + }); + } + } + // The blob's index is what the wallet runs on; the column is what + // the write-path slot guard (`check_index_conflicts`) reasons about. + // Divergence means the guard has been policing a value the runtime + // never sees, so the two must be proven equal on the way in. + let typed_identity_index = typed_identity_index + .map(|raw| crate::sqlite::util::safe_cast::i64_to_u32("identities.identity_index", raw)) + .transpose()?; + if entry.identity_index != typed_identity_index { + return Err(WalletStorageError::IdentityEntryIdMismatch); + } let ignored = ignored_by_owner.remove(&entry.id).unwrap_or_default(); let managed = managed_identity_from_entry(&entry, wallet_id, ignored); match entry.identity_index { Some(idx) => { - state + let entry_id = entry.id; + if let Some(displaced) = state .wallet_identities .entry(*wallet_id) .or_default() - .insert(idx, managed); + .insert(idx, managed) + { + // `identities` carries no `(wallet_id, identity_index)` + // UNIQUE index, so a pre-guard duplicate can still be on + // disk. Nothing in the persisted rows establishes which + // identity genuinely holds `idx`, so the slot winner is + // arbitrary on the merits — which is exactly why the loser + // MUST NOT be dropped. Park it in + // `out_of_wallet_identities` (the same bucket a NULL + // `identity_index` row lands in) so a Recovery load hands + // the caller every identity it read, slot or no slot. The + // persister is read-only in Recovery, so anything dropped + // here could never be recovered from a later flush. + let displaced_identity_id = displaced.identity.id(); + state + .out_of_wallet_identities + .insert(displaced_identity_id, displaced); + ctx.tolerate( + LoadSite::IdentityIndexCollision, + WalletStorageError::IdentityIndexConflict { + wallet_id: *wallet_id, + identity_index: idx, + existing: displaced_identity_id.to_buffer(), + incoming: entry_id.to_buffer(), + }, + )?; + } } None => { state.out_of_wallet_identities.insert(entry.id, managed); @@ -191,11 +419,82 @@ pub fn load_state( Ok(state) } +/// Build a fully pre-keyed +/// [`IdentityManagerStartState`](platform_wallet::changeset::IdentityManagerStartState) +/// for one wallet: read the identities, then fold this wallet's persisted +/// identity keys and contacts onto them so every `ManagedIdentity` carries +/// its own `public_keys` and contact maps at load time — no separate +/// changeset layered on afterwards. Fail-hard on a corrupt row (inherited +/// from the three underlying readers) and on any merged key / contact entry +/// whose owner is absent for a reason other than a known tombstone; a +/// tombstoned owner's orphaned rows are skipped with a summary log (see +/// [`merge_contacts_and_keys`]). +pub fn load_prekeyed( + conn: &Connection, + wallet_id: &WalletId, + ctx: &LoadCtx, +) -> Result { + let mut state = load_state_with_ctx(conn, wallet_id, ctx)?; + let identity_keys = crate::sqlite::schema::identity_keys::load_state(conn, wallet_id, ctx)?; + let records = crate::sqlite::schema::contacts::load_state(conn, wallet_id, ctx)?; + // Ignored senders restore in `load_state` from the authoritative + // `ignored_senders` table, so only the request / established maps ride + // this changeset; `removed_*` / `ignored` / `unignored` stay empty. + let contacts = platform_wallet::changeset::ContactChangeSet { + sent_requests: records.sent_requests, + incoming_requests: records.incoming_requests, + established: records.established, + ..Default::default() + }; + let tombstoned = load_tombstoned_ids(conn, wallet_id)?; + merge_contacts_and_keys( + &mut state, + contacts, + identity_keys, + &tombstoned, + *wallet_id, + ctx, + )?; + // The scan verdict rides the same per-wallet start state the identities + // do, because it is the fact the startup sequence weighs against them: + // "we already have one" is not evidence we have them all unless the scan + // behind it answered every index (dashpay/platform#4365). + if let Some(scan_state) = + crate::sqlite::schema::identity_scan_states::load_for_wallet(conn, wallet_id, ctx)? + { + state.scan_states.insert(*wallet_id, scan_state); + } + Ok(state) +} + +/// The set of identity ids tombstoned (logically deleted) for this wallet. +/// A rehydration-merge entry whose owner is in this set is an expected +/// logical-delete orphan — safe to skip; an owner absent for any other +/// reason is a hard error. +fn load_tombstoned_ids( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + // NULL-safe `IS`, matching `load_state`: a tombstoned UNOWNED identity + // must be recognised as tombstoned too, else its leftover key rows are + // treated as inexplicable orphans and hard-error the read. + let wallet_id_param = wallet_id_to_param(wallet_id); + let mut stmt = conn + .prepare("SELECT identity_id FROM identities WHERE wallet_id IS ?1 AND tombstoned = 1")?; + let mut rows = stmt.query(params![wallet_id_param])?; + let mut out = HashSet::new(); + while let Some(row) = rows.next()? { + let id_bytes: Vec = row.get(0)?; + let id32 = super::id32("identities.identity_id", &id_bytes)?; + out.insert(Identifier::from(id32)); + } + Ok(out) +} + /// 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. -#[cfg(any(test, feature = "__test-helpers"))] fn managed_identity_from_entry( entry: &IdentityEntry, wallet_id: &WalletId, @@ -219,7 +518,14 @@ fn managed_identity_from_entry( managed.status = entry.status; managed.dpns_names = entry.dpns_names.clone(); managed.contested_dpns_names = entry.contested_dpns_names.clone(); - managed.wallet_id = entry.wallet_id.or(Some(*wallet_id)); + // Fall back to the reading scope, EXCEPT for the sentinel: an identity + // read from the unowned bucket must come back with `wallet_id: None`, + // not `Some([0; 32])`. The sentinel is a storage spelling of "no + // wallet", never a wallet id, and handing it out would put a value + // that looks like an owner onto an identity that has none. + managed.wallet_id = entry + .wallet_id + .or_else(|| (!wallet_id.iter().all(|b| *b == 0)).then_some(*wallet_id)); // Scalar-snapshot collections ride the identity `entry_blob` // (payments / profile / contact_profiles), so they restore from // `entry`. The relational request collections are loaded separately @@ -248,12 +554,10 @@ fn managed_identity_from_entry( managed } -/// 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`]. +/// Insert a stub identity row (test helper) so identity_keys / +/// dashpay_profiles can reference it via their FK. The stub carries a +/// `null`-encoded `IdentityEntry` so `entry_blob` always decodes; real data +/// overwrites via [`apply`]. #[cfg(any(test, feature = "__test-helpers"))] pub fn ensure_exists( conn: &Connection, @@ -283,9 +587,1141 @@ pub fn ensure_exists( 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) \ + (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ VALUES (?1, ?2, NULL, ?3, 0)", params![&identity_id[..], wallet_id_param, payload], )?; Ok(()) } + +/// Fold persisted PUBLIC keys and contact state onto the already-built +/// managed identities so `Identity.public_keys` and the contact maps +/// are populated at load time — the FFI persister's pre-keyed shape, +/// with no separate changeset layered on afterwards. +/// +/// Entries route by owner `identity_id` across BOTH buckets. Only key +/// `upserts` and the `sent` / `incoming` / `established` maps are routed; +/// `removed_*` (insert-only feed) and `ignored` / `unignored` (restored in +/// the identity reader from the `ignored_senders` table) are skipped. No +/// `Network` needed — key insert is network-independent. +/// +/// # Errors +/// +/// [`WalletStorageError::OrphanedIdentityEntry`] when an entry's owner is +/// absent from the loaded set. A known-tombstoned owner is the one case +/// [`LoadPolicy::Recovery`](crate::LoadPolicy) forgives: its rows are +/// logical-delete leftovers, skipped and summarised once per collection. +/// Under `Strict` even those abort, because "the owner is gone" is exactly +/// the state that silently drops live key / contact material. +pub fn merge_contacts_and_keys( + state: &mut IdentityManagerStartState, + contacts: ContactChangeSet, + identity_keys: IdentityKeysChangeSet, + tombstoned: &HashSet, + wallet_id: WalletId, + ctx: &LoadCtx, +) -> Result<(), WalletStorageError> { + // One transient id → &mut ManagedIdentity view over both buckets so + // routing is O(1) per entry rather than a per-entry bucket scan. The + // two buckets are disjoint fields, so their mutable borrows coexist. + let mut by_id: HashMap = HashMap::new(); + for managed in state.out_of_wallet_identities.values_mut() { + by_id.insert(managed.identity.id(), managed); + } + for inner in state.wallet_identities.values_mut() { + for managed in inner.values_mut() { + by_id.insert(managed.identity.id(), managed); + } + } + + route_by_owner( + identity_keys + .upserts + .into_values() + .map(|entry| (entry.identity_id, entry.public_key)), + &mut by_id, + tombstoned, + wallet_id, + ctx, + "identity_keys", + |managed, key| managed.identity.add_public_key(key), + )?; + route_by_owner( + contacts + .sent_requests + .into_iter() + .map(|(key, entry)| (key.owner_id, entry.request)), + &mut by_id, + tombstoned, + wallet_id, + ctx, + "sent_contact_requests", + |managed, request| managed.apply_sent_contact_request(request), + )?; + route_by_owner( + contacts + .incoming_requests + .into_iter() + .map(|(key, entry)| (key.owner_id, entry.request)), + &mut by_id, + tombstoned, + wallet_id, + ctx, + "incoming_contact_requests", + |managed, request| managed.apply_incoming_contact_request(request), + )?; + route_by_owner( + contacts + .established + .into_iter() + .map(|(key, established)| (key.owner_id, established)), + &mut by_id, + tombstoned, + wallet_id, + ctx, + "established_contacts", + |managed, established| managed.apply_established_contact(established), + )?; + + Ok(()) +} + +/// Apply one collection's entries to their owning identity. +/// +/// An entry whose owner is loaded is applied. An entry whose owner is +/// tombstoned is counted and, if the policy allows it, skipped — decided +/// once after the walk rather than per entry, so a wallet with thousands of +/// leftovers produces one log line, not thousands, while the per-site tally +/// still counts every skipped row. Any other missing owner is fatal in both +/// policies. +fn route_by_owner( + entries: impl IntoIterator, + by_id: &mut HashMap, + tombstoned: &HashSet, + wallet_id: WalletId, + ctx: &LoadCtx, + collection: &'static str, + apply: impl Fn(&mut ManagedIdentity, T), +) -> Result<(), WalletStorageError> { + let mut skipped = 0usize; + let mut first_skipped_owner: Option = None; + for (owner, payload) in entries { + match by_id.get_mut(&owner) { + Some(managed) => apply(managed, payload), + None if tombstoned.contains(&owner) => { + skipped += 1; + first_skipped_owner.get_or_insert(owner); + } + None => { + return Err(WalletStorageError::OrphanedIdentityEntry { + owner: owner.to_buffer(), + }) + } + } + } + if let Some(owner) = first_skipped_owner { + ctx.tolerate_at( + LoadSite::TombstonedIdentityOrphan, + SiteCoords { + wallet_id: Some(wallet_id), + account_type: &"identity", + affected: skipped, + detail: Some(&collection), + }, + WalletStorageError::OrphanedIdentityEntry { + owner: owner.to_buffer(), + }, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::prelude::Identifier; + use platform_wallet::changeset::IdentityChangeSet; + use platform_wallet::wallet::identity::IdentityStatus; + + fn migrated_conn() -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + fn insert_wallet(conn: &Connection, wallet: &[u8; 32]) { + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet[..]], + ) + .unwrap(); + } + + fn entry( + id: [u8; 32], + wallet_id: Option<[u8; 32]>, + balance: u64, + index: Option, + ) -> IdentityEntry { + IdentityEntry { + id: Identifier::from(id), + balance, + revision: 0, + identity_index: index, + 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, + dashpay_profile: None, + dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } + } + + fn apply_in_tx(conn: &mut Connection, scope: &[u8; 32], cs: &IdentityChangeSet) { + let tx = conn.transaction().unwrap(); + apply(&tx, scope, cs).unwrap(); + tx.commit().unwrap(); + } + + /// A wallet-B flush naming an identity already owned by wallet A must NOT + /// overwrite A's blob / index or clear A's tombstone — the DO UPDATE WHERE + /// scopes the overwrite to the owning wallet, so the cross-wallet write is + /// a no-op. + #[test] + fn cross_wallet_upsert_does_not_overwrite_resident_row() { + let mut conn = migrated_conn(); + let a = [0xA1u8; 32]; + let b = [0xB2u8; 32]; + let x = [0x01u8; 32]; + insert_wallet(&conn, &a); + insert_wallet(&conn, &b); + + // A registers X (balance 1000, index 5), then tombstones it. + let mut cs_a = IdentityChangeSet::default(); + cs_a.identities + .insert(Identifier::from(x), entry(x, Some(a), 1000, Some(5))); + apply_in_tx(&mut conn, &a, &cs_a); + let mut cs_a_remove = IdentityChangeSet::default(); + cs_a_remove.removed.insert(Identifier::from(x)); + apply_in_tx(&mut conn, &a, &cs_a_remove); + + // B flushes X (balance 2000, index 9, unowned blob). Must be a no-op. + let mut cs_b = IdentityChangeSet::default(); + cs_b.identities + .insert(Identifier::from(x), entry(x, None, 2000, Some(9))); + apply_in_tx(&mut conn, &b, &cs_b); + + let (resident, tombstoned) = fetch(&conn, &a, &x).unwrap().expect("A still owns the row"); + assert_eq!(resident.balance, 1000, "A's blob must survive B's write"); + assert_eq!(resident.identity_index, Some(5), "A's index must survive"); + assert!(tombstoned, "A's tombstone must not be reset by B"); + assert!( + fetch(&conn, &b, &x).unwrap().is_none(), + "B must not have taken ownership" + ); + } + + /// The WHERE still permits the orphan → parented promotion path: an + /// unowned (NULL wallet_id) row is claimed by the first wallet to flush it. + #[test] + fn orphan_promotion_still_applies() { + let mut conn = migrated_conn(); + let a = [0xA1u8; 32]; + let y = [0x02u8; 32]; + insert_wallet(&conn, &a); + + // Orphan Y under the sentinel scope (NULL wallet_id). + let mut cs_orphan = IdentityChangeSet::default(); + cs_orphan + .identities + .insert(Identifier::from(y), entry(y, None, 10, None)); + apply_in_tx(&mut conn, &[0u8; 32], &cs_orphan); + assert!( + fetch(&conn, &a, &y).unwrap().is_none(), + "Y starts unowned by A" + ); + + // A claims Y (balance 500, index 3). + let mut cs_a = IdentityChangeSet::default(); + cs_a.identities + .insert(Identifier::from(y), entry(y, Some(a), 500, Some(3))); + apply_in_tx(&mut conn, &a, &cs_a); + + let (claimed, _) = fetch(&conn, &a, &y).unwrap().expect("A claimed Y"); + assert_eq!(claimed.balance, 500, "promotion applies the new blob"); + assert_eq!(claimed.identity_index, Some(3)); + } + + /// `load_prekeyed` folds each identity's persisted keys onto it across + /// BOTH buckets — a wallet-owned identity (`identity_index = Some`) and + /// an out-of-wallet one (`identity_index = None`) each receive their own + /// key, with no cross-attribution. + #[test] + fn load_prekeyed_populates_keys_in_both_buckets() { + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use platform_wallet::changeset::{IdentityKeyEntry, IdentityKeysChangeSet}; + + let mut conn = migrated_conn(); + let w = [0x0Au8; 32]; + insert_wallet(&conn, &w); + + let wallet_owned = Identifier::from([0x11u8; 32]); + let out_of_wallet = Identifier::from([0x22u8; 32]); + + let mut ids = IdentityChangeSet::default(); + ids.identities + .insert(wallet_owned, entry([0x11; 32], Some(w), 100, Some(0))); + ids.identities + .insert(out_of_wallet, entry([0x22; 32], Some(w), 200, None)); + + let key = |id: Identifier, byte: u8| IdentityKeyEntry { + identity_id: id, + key_id: 0, + 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![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + }; + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts + .insert((wallet_owned, 0), key(wallet_owned, 0xA1)); + keys.upserts + .insert((out_of_wallet, 0), key(out_of_wallet, 0xB2)); + + let tx = conn.transaction().unwrap(); + apply(&tx, &w, &ids).unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &w, &keys).unwrap(); + tx.commit().unwrap(); + + let state = load_prekeyed(&conn, &w, &LoadCtx::strict()).unwrap(); + let wo = &state.wallet_identities[&w][&0]; + assert_eq!( + wo.identity.public_keys()[&0].data().as_slice(), + &[0xA1; 33], + "wallet-owned identity carries its own key" + ); + let oow = &state.out_of_wallet_identities[&out_of_wallet]; + assert_eq!( + oow.identity.public_keys()[&0].data().as_slice(), + &[0xB2; 33], + "out-of-wallet identity carries its own key" + ); + } + + /// Promoting an orphan identity into a wallet must carry its + /// NULL-scoped `identity_keys` rows across in the same transaction. + /// Left behind, those keys stay NULL-scoped while their identity is + /// wallet-owned — a state the `identity_keys_null_scope_*` triggers + /// exist to forbid, and one that hides the keys from the promoted + /// identity's own scoped read. + #[test] + fn orphan_promotion_rescopes_the_identitys_null_scoped_keys() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let a = [0xA1u8; 32]; + let y = [0x02u8; 32]; + insert_wallet(&conn, &a); + + // Orphan Y under the sentinel scope, then give it a NULL-scoped key. + let mut cs_orphan = IdentityChangeSet::default(); + cs_orphan + .identities + .insert(Identifier::from(y), entry(y, None, 10, None)); + apply_in_tx(&mut conn, &[0u8; 32], &cs_orphan); + + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert( + (Identifier::from(y), 0), + sample_key_entry(Identifier::from(y), 0x09), + ); + let tx = conn.transaction().unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &[0u8; 32], &keys).unwrap(); + tx.commit().unwrap(); + + // A claims Y. + let mut cs_a = IdentityChangeSet::default(); + cs_a.identities + .insert(Identifier::from(y), entry(y, Some(a), 500, Some(3))); + apply_in_tx(&mut conn, &a, &cs_a); + + let orphaned: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys \ + WHERE identity_id = ?1 AND wallet_id IS NULL", + params![&y[..]], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + orphaned, 0, + "the promoted identity's keys must not stay NULL-scoped" + ); + + let rescoped: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys \ + WHERE identity_id = ?1 AND wallet_id IS ?2", + params![&y[..], &a[..]], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(rescoped, 1, "the key must move to the promoting wallet"); + } + + /// A NULL-scoped key must name an identity that actually exists. V001's + /// guard only rejected keys whose identity was wallet-OWNED, so one + /// naming no identity at all slipped through — MATCH SIMPLE leaves both + /// foreign keys dormant on a NULL-scoped row, making the trigger the + /// only guard there is. Closed by V015. + #[test] + fn null_scoped_key_is_rejected_for_a_missing_identity() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let ghost = Identifier::from([0x5Eu8; 32]); // never written to `identities` + + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts + .insert((ghost, 0), sample_key_entry(ghost, 0x5E)); + + let tx = conn.transaction().unwrap(); + let err = crate::sqlite::schema::identity_keys::apply(&tx, &[0u8; 32], &keys) + .expect_err("a NULL-scoped key naming no identity must be rejected"); + assert!( + matches!(err, WalletStorageError::IdentityKeyWalletMismatch { .. }), + "expected IdentityKeyWalletMismatch from the NULL-scope trigger, got {err:?}" + ); + } + + /// Two live rows of one wallet claiming the same `identity_index` must + /// not resolve by silently dropping whichever the scan reached first. + /// `identities` has no `(wallet_id, identity_index)` UNIQUE index, so a + /// duplicate written before the write-path slot guard existed is still + /// reachable on disk. + #[test] + fn load_state_reports_a_duplicate_identity_index_instead_of_dropping_one() { + let conn = migrated_conn(); + let wallet = [7u8; 32]; + insert_wallet(&conn, &wallet); + for id_byte in [0xAAu8, 0xBBu8] { + let e = entry([id_byte; 32], Some(wallet), 100, Some(1)); + let payload = blob::encode(&e).unwrap(); + conn.execute( + "INSERT INTO identities \ + (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 1, ?3, 0)", + params![&[id_byte; 32][..], &wallet[..], payload], + ) + .unwrap(); + } + + // Strict: the contradiction is fatal rather than silently resolved. + let err = load_state_with_ctx(&conn, &wallet, &LoadCtx::strict()) + .expect_err("a duplicated derivation slot must fail a strict load"); + assert!( + matches!(err, WalletStorageError::IdentityIndexConflict { .. }), + "expected IdentityIndexConflict, got {err:?}" + ); + + // Recovery: still degraded, but counted and logged rather than silent. + let ctx = LoadCtx::recovery(); + let state = load_state_with_ctx(&conn, &wallet, &ctx) + .expect("recovery tolerates the duplicate to get the wallet open"); + assert_eq!( + state.wallet_identities.get(&wallet).map(|m| m.len()), + Some(1), + "one identity still loses the slot — the point is that it is reported" + ); + let degradation = ctx.degradation(); + assert!(degradation.degraded); + assert_eq!( + degradation.by_site.get(&LoadSite::IdentityIndexCollision), + Some(&1), + "the collision must be counted against its own site" + ); + } + + /// The `identity_index` COLUMN and the index inside `entry_blob` are two + /// copies of one fact. The write-path slot guard polices the column while + /// the wallet runs on the blob, so a load must refuse a row where they + /// disagree rather than let the guard protect a value nothing reads. + #[test] + fn load_state_rejects_identity_index_column_drift() { + let conn = migrated_conn(); + let wallet = [8u8; 32]; + insert_wallet(&conn, &wallet); + let e = entry([0xC1u8; 32], Some(wallet), 100, Some(2)); + let payload = blob::encode(&e).unwrap(); + conn.execute( + "INSERT INTO identities \ + (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 9, ?3, 0)", + params![&[0xC1u8; 32][..], &wallet[..], payload], + ) + .unwrap(); + + let err = + load_state(&conn, &wallet).expect_err("column/blob index drift must fail the load"); + assert!( + matches!(err, WalletStorageError::IdentityEntryIdMismatch), + "expected IdentityEntryIdMismatch, got {err:?}" + ); + } + + fn sample_key_entry(id: Identifier, byte: u8) -> platform_wallet::changeset::IdentityKeyEntry { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + platform_wallet::changeset::IdentityKeyEntry { + identity_id: id, + key_id: 0, + 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![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + } + } + + /// An `identity_keys` write naming an identity owned by a DIFFERENT + /// wallet is rejected at write time by the compound FK. This is where + /// the guarantee now lives: the unreadable row never reaches disk, so + /// the load-time orphan check can't be reached by this route. + #[test] + fn identity_key_write_is_rejected_for_a_non_owning_wallet() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let a = [0xA1u8; 32]; + let b = [0xB2u8; 32]; + insert_wallet(&conn, &a); + insert_wallet(&conn, &b); + + // Identity X is parented to wallet B. + let x = Identifier::from([0x33u8; 32]); + let mut ids_b = IdentityChangeSet::default(); + ids_b + .identities + .insert(x, entry([0x33; 32], Some(b), 100, Some(0))); + apply_in_tx(&mut conn, &b, &ids_b); + + // Filing X's key under wallet A must fail — A does not own X. + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((x, 0), sample_key_entry(x, 0xC3)); + { + let tx = conn.transaction().unwrap(); + let err = crate::sqlite::schema::identity_keys::apply(&tx, &a, &keys) + .expect_err("a key for a non-owning wallet must be rejected"); + assert!( + matches!( + err, + WalletStorageError::IdentityKeyWalletMismatch { + wallet_id, identity_id, .. + } if wallet_id == a && identity_id == x.to_buffer() + ), + "expected IdentityKeyWalletMismatch naming wallet A and identity X, got {err:?}" + ); + } + + // Nothing was written: A's load is clean rather than fatally orphaned. + let state = + load_prekeyed(&conn, &a, &LoadCtx::strict()).expect("no orphan row was ever created"); + assert!(state + .wallet_identities + .get(&a) + .is_none_or(|inner| inner.is_empty())); + } + + /// The reported top-up corruption, at the storage seam: identity owned + /// by wallet A, its keys flushed under wallet B's scope. Before the + /// compound FK these rows landed silently and bricked the next load; + /// now the write is refused and the file stays loadable. + #[test] + fn cross_wallet_key_flush_cannot_brick_a_wallet_file() { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let owner = [0xFAu8; 32]; + let payer = [0xC5u8; 32]; + insert_wallet(&conn, &owner); + insert_wallet(&conn, &payer); + + let identity = Identifier::from([0xA7u8; 32]); + let mut ids = IdentityChangeSet::default(); + ids.identities + .insert(identity, entry([0xA7; 32], Some(owner), 500, Some(0))); + let mut owner_keys = IdentityKeysChangeSet::default(); + owner_keys + .upserts + .insert((identity, 0), sample_key_entry(identity, 0x11)); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &owner, &ids).unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &owner, &owner_keys).unwrap(); + tx.commit().unwrap(); + } + + // The payer wallet re-files the same keys under its own scope. + let mut payer_keys = IdentityKeysChangeSet::default(); + payer_keys + .upserts + .insert((identity, 0), sample_key_entry(identity, 0x11)); + { + let tx = conn.transaction().unwrap(); + let err = crate::sqlite::schema::identity_keys::apply(&tx, &payer, &payer_keys) + .expect_err("the paying wallet does not own the identity"); + assert!( + matches!(err, WalletStorageError::IdentityKeyWalletMismatch { .. }), + "expected IdentityKeyWalletMismatch, got {err:?}" + ); + } + + // Both wallets still load; the owner keeps its key. + let owner_state = + load_prekeyed(&conn, &owner, &LoadCtx::strict()).expect("owner wallet still loads"); + assert_eq!( + owner_state.wallet_identities[&owner][&0] + .identity + .public_keys()[&0] + .data() + .as_slice(), + &[0x11; 33] + ); + load_prekeyed(&conn, &payer, &LoadCtx::strict()).expect("payer wallet still loads"); + } + + /// The cross-wallet write refused above must stay refused on the + /// UPSERT path specifically. With `PRIMARY KEY (identity_id, key_id)` + /// a foreign wallet's write for an existing key no longer arrives as + /// an INSERT — it collides and resolves to `DO UPDATE`, and an UPDATE + /// that leaves `wallet_id` alone violates no foreign key. The upsert + /// therefore assigns `wallet_id = excluded.wallet_id` so the mismatch + /// still trips the compound FK. Drop that one clause and this test + /// fails: the write returns `Ok` and the payer's key material + /// silently replaces the owner's under the owner's own scope. + #[test] + fn identity_key_upsert_cannot_overwrite_another_wallets_key() { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let owner = [0x0Eu8; 32]; + let payer = [0x0Fu8; 32]; + insert_wallet(&conn, &owner); + insert_wallet(&conn, &payer); + + // Owner holds identity X and its key 0, carrying `0x11` material. + let x = Identifier::from([0xE1u8; 32]); + let mut ids = IdentityChangeSet::default(); + ids.identities + .insert(x, entry([0xE1; 32], Some(owner), 700, Some(0))); + let mut owner_keys = IdentityKeysChangeSet::default(); + owner_keys.upserts.insert((x, 0), sample_key_entry(x, 0x11)); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &owner, &ids).unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &owner, &owner_keys).unwrap(); + tx.commit().unwrap(); + } + + // The payer re-files THE SAME `(identity_id, key_id)` with + // different material — the exact key collision the narrowed + // primary key turns into an update rather than an insert. + let mut payer_keys = IdentityKeysChangeSet::default(); + payer_keys.upserts.insert((x, 0), sample_key_entry(x, 0x22)); + { + let tx = conn.transaction().unwrap(); + let err = crate::sqlite::schema::identity_keys::apply(&tx, &payer, &payer_keys) + .expect_err("an upsert onto another wallet's key must be rejected"); + assert!( + matches!( + err, + WalletStorageError::IdentityKeyWalletMismatch { + wallet_id, identity_id, .. + } if wallet_id == payer && identity_id == x.to_buffer() + ), + "expected IdentityKeyWalletMismatch naming the payer and identity X, got {err:?}" + ); + } + + // Distinct material on each side, so this assertion catches a + // silent overwrite as well as a missing error. + let owner_state = + load_prekeyed(&conn, &owner, &LoadCtx::strict()).expect("owner wallet still loads"); + assert_eq!( + owner_state.wallet_identities[&owner][&0] + .identity + .public_keys()[&0] + .data() + .as_slice(), + &[0x11; 33], + "the owner's key material must survive the payer's upsert" + ); + } + + /// The unowned scope round-trips: a key written under the all-zero + /// sentinel lands with a genuine SQL NULL `wallet_id` (not 32 zero + /// bytes) against an identity that is itself unowned. Both foreign + /// keys are dormant here — MATCH SIMPLE skips enforcement once any + /// child key column is NULL — so this is the case the guards must + /// permit rather than the case they catch. + #[test] + fn null_scoped_key_is_accepted_for_an_unowned_identity() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let unowned = [0u8; 32]; + + // Identity Z exists with no owning wallet. + let z = Identifier::from([0x5Au8; 32]); + let mut ids = IdentityChangeSet::default(); + ids.identities.insert(z, entry([0x5A; 32], None, 10, None)); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((z, 0), sample_key_entry(z, 0x5B)); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &unowned, &ids).unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &unowned, &keys) + .expect("an unowned key on an unowned identity is legitimate"); + tx.commit().unwrap(); + } + + // NULL, not a 32-byte zero blob: the distinction the readers and + // both guards key on. + let is_null: bool = conn + .query_row( + "SELECT wallet_id IS NULL FROM identity_keys WHERE identity_id = ?1", + params![&z.to_buffer()[..]], + |row| row.get(0), + ) + .unwrap(); + assert!(is_null, "the sentinel scope must store SQL NULL"); + + // Re-saving the same key exercises the DO UPDATE path, which the + // BEFORE UPDATE trigger also inspects — it must stay permitted. + let mut resave = IdentityKeysChangeSet::default(); + resave.upserts.insert((z, 0), sample_key_entry(z, 0x5C)); + { + let tx = conn.transaction().unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &unowned, &resave) + .expect("re-saving an unowned key must stay permitted"); + tx.commit().unwrap(); + } + } + + /// The NULL door into the corruption the compound FK closed: with a + /// NULL `wallet_id` both FKs go dormant, so nothing at the FK level + /// stops an unowned key from naming a WALLET-OWNED identity — a row + /// no per-wallet reader can resolve. The trigger pair is what rejects + /// it, and it must surface as the same typed error as the FK path. + /// + /// Both statement paths are exercised: the INSERT trigger, and the + /// UPDATE trigger reached by an upsert colliding on an existing + /// `(identity_id, key_id)`. Only the second catches a re-save, which + /// is the shape ordinary writes take. + #[test] + fn null_scoped_key_is_rejected_for_a_wallet_owned_identity() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let owner = [0xD1u8; 32]; + let unowned = [0u8; 32]; + insert_wallet(&conn, &owner); + + // Identity X is owned by a real wallet. + let x = Identifier::from([0xD2u8; 32]); + let mut ids = IdentityChangeSet::default(); + ids.identities + .insert(x, entry([0xD2; 32], Some(owner), 300, Some(0))); + apply_in_tx(&mut conn, &owner, &ids); + + // Both FKs are dormant for a NULL scope, so a rejection here can + // only have come from the trigger. Assert that positively via + // the extended result code (1811 = SQLITE_CONSTRAINT_TRIGGER) + // rather than inferring it, so the test still proves the trigger + // fired if some future guard starts rejecting earlier. + let assert_raised_by_trigger = |err: &WalletStorageError| match err { + WalletStorageError::IdentityKeyWalletMismatch { source, .. } => match source.as_ref() { + rusqlite::Error::SqliteFailure(e, _) => assert_eq!( + e.extended_code, 1811, + "rejection must come from the NULL-scope trigger, not an FK" + ), + other => panic!("expected a SqliteFailure source, got {other:?}"), + }, + other => panic!("expected IdentityKeyWalletMismatch, got {other:?}"), + }; + + // INSERT path: no row for (X, 0) yet, so this reaches the + // BEFORE INSERT trigger. + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((x, 0), sample_key_entry(x, 0xD3)); + { + let tx = conn.transaction().unwrap(); + let err = crate::sqlite::schema::identity_keys::apply(&tx, &unowned, &keys) + .expect_err("an unowned key may not name a wallet-owned identity"); + assert!( + matches!( + err, + WalletStorageError::IdentityKeyWalletMismatch { + wallet_id, identity_id, .. + } if wallet_id == unowned && identity_id == x.to_buffer() + ), + "expected IdentityKeyWalletMismatch from the INSERT trigger, got {err:?}" + ); + assert_raised_by_trigger(&err); + } + + // UPDATE path: stage the key legitimately under its owner first, + // so the unowned write now COLLIDES and resolves to DO UPDATE — + // which the BEFORE INSERT trigger never sees. + let mut owner_keys = IdentityKeysChangeSet::default(); + owner_keys.upserts.insert((x, 0), sample_key_entry(x, 0xD4)); + { + let tx = conn.transaction().unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &owner, &owner_keys).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let err = crate::sqlite::schema::identity_keys::apply(&tx, &unowned, &keys) + .expect_err("the UPDATE path must be guarded too"); + assert!( + matches!(err, WalletStorageError::IdentityKeyWalletMismatch { .. }), + "expected IdentityKeyWalletMismatch from the UPDATE trigger, got {err:?}" + ); + assert_raised_by_trigger(&err); + } + + // The owner's row is untouched: still owned, still its own material. + let (scope, blob_head): (Option>, Vec) = conn + .query_row( + "SELECT wallet_id, public_key_blob FROM identity_keys WHERE identity_id = ?1", + params![&x.to_buffer()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!( + scope.as_deref(), + Some(&owner[..]), + "the owner's scope must survive the rejected unowned write" + ); + assert!(!blob_head.is_empty()); + } + + /// A NULL-scoped key must be deletable. The delete carries a scope + /// guard, and with a plain `wallet_id = ?1` that guard can never + /// match NULL: the statement would succeed, remove nothing, and + /// report `Ok` — an unowned key that no caller can ever erase. + #[test] + fn null_scoped_key_can_be_deleted() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let unowned = [0u8; 32]; + let z = Identifier::from([0x6Au8; 32]); + + let mut ids = IdentityChangeSet::default(); + ids.identities.insert(z, entry([0x6A; 32], None, 20, None)); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((z, 0), sample_key_entry(z, 0x6B)); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &unowned, &ids).unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &unowned, &keys).unwrap(); + tx.commit().unwrap(); + } + + let mut removal = IdentityKeysChangeSet::default(); + removal.removed.insert((z, 0)); + { + let tx = conn.transaction().unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &unowned, &removal).unwrap(); + tx.commit().unwrap(); + } + + let remaining: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys WHERE identity_id = ?1", + params![&z.to_buffer()[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(remaining, 0, "the unowned key must actually be deleted"); + } + + /// `load_prekeyed` skips — never hard-errors on — an `identity_keys` + /// entry whose owner is a known-tombstoned identity: those orphaned rows + /// are the expected, self-explained fallout of a logical delete. + #[test] + fn load_prekeyed_skips_orphaned_keys_of_tombstoned_owner_in_recovery() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let a = [0xA7u8; 32]; + insert_wallet(&conn, &a); + let y = Identifier::from([0x44u8; 32]); + + let mut ids = IdentityChangeSet::default(); + ids.identities + .insert(y, entry([0x44; 32], Some(a), 50, Some(0))); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((y, 0), sample_key_entry(y, 0xD4)); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &a, &ids).unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &a, &keys).unwrap(); + tx.commit().unwrap(); + } + // Tombstone Y; its key row survives as a logical-delete orphan. + let mut removed = IdentityChangeSet::default(); + removed.removed.insert(y); + apply_in_tx(&mut conn, &a, &removed); + + let strict = load_prekeyed(&conn, &a, &LoadCtx::strict()) + .expect_err("a tombstoned owner's leftover key must abort a strict load"); + assert!( + matches!(strict, WalletStorageError::OrphanedIdentityEntry { .. }), + "expected OrphanedIdentityEntry, got {strict:?}" + ); + + let state = load_prekeyed(&conn, &a, &LoadCtx::recovery()) + .expect("tombstoned-owner orphan must be skipped in recovery, not fatal"); + assert!( + state + .wallet_identities + .get(&a) + .map(|m| m.is_empty()) + .unwrap_or(true), + "tombstoned identity must not surface in the loaded state" + ); + } + + /// The same logical-delete skip, for a tombstoned UNOWNED identity. + /// + /// Not a duplicate of the wallet-owned case above: the skip depends on + /// `load_tombstoned_ids` recognising the owner as tombstoned, and that + /// query is scoped by `wallet_id`. Scoped with `=` it cannot match a + /// NULL, so an unowned tombstone is invisible, its surviving key rows + /// look like owners that vanished for no reason, and the read fails + /// with `OrphanedIdentityEntry` — the exact brick this whole line of + /// work exists to prevent, re-entering through the unowned door. The + /// NULL-safe `IS` is what closes it, and this test is what holds it + /// closed: revert that predicate to `= ?1` and this fails. + #[test] + fn load_prekeyed_skips_orphaned_keys_of_tombstoned_unowned_owner_in_recovery() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let unowned = [0u8; 32]; + let z = Identifier::from([0x9Cu8; 32]); + + let mut ids = IdentityChangeSet::default(); + ids.identities.insert(z, entry([0x9C; 32], None, 30, None)); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((z, 0), sample_key_entry(z, 0x9D)); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &unowned, &ids).unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &unowned, &keys).unwrap(); + tx.commit().unwrap(); + } + + // Tombstone Z. Its key row survives — a logical delete tombstones + // the identity, it does not reap the children. + let mut removed = IdentityChangeSet::default(); + removed.removed.insert(z); + apply_in_tx(&mut conn, &unowned, &removed); + let surviving_keys: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys WHERE identity_id = ?1", + params![&z.to_buffer()[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + surviving_keys, 1, + "the orphaned key row must actually exist, or this test proves nothing" + ); + + let state = load_prekeyed(&conn, &unowned, &LoadCtx::recovery()) + .expect("a tombstoned UNOWNED owner's orphan key must be skipped in recovery"); + assert!( + state.out_of_wallet_identities.is_empty(), + "the tombstoned identity must not surface in the loaded state" + ); + } + + /// The delete's scope guard, which nothing else holds in place. + /// + /// `(identity_id, key_id)` is the primary key, so `wallet_id` in the + /// DELETE's WHERE is no longer needed to SELECT the row — which makes + /// removing it look like an obvious tidy-up. It is not: it is what + /// stops one wallet's `removed` set from deleting another wallet's + /// key. Without it that cross-wallet delete succeeds, and a + /// permissive deletion is a worse failure than a permissive no-op. + #[test] + fn identity_key_delete_cannot_reach_another_wallets_key() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let owner = [0xC1u8; 32]; + let other = [0xC2u8; 32]; + insert_wallet(&conn, &owner); + insert_wallet(&conn, &other); + + let x = Identifier::from([0xC3u8; 32]); + let mut ids = IdentityChangeSet::default(); + ids.identities + .insert(x, entry([0xC3; 32], Some(owner), 600, Some(0))); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((x, 0), sample_key_entry(x, 0xC4)); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &owner, &ids).unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &owner, &keys).unwrap(); + tx.commit().unwrap(); + } + + // The other wallet asks for the same key to be removed. Scoped + // out, this is a clean no-op rather than an error. + let mut removal = IdentityKeysChangeSet::default(); + removal.removed.insert((x, 0)); + { + let tx = conn.transaction().unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &other, &removal).unwrap(); + tx.commit().unwrap(); + } + + let surviving: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys WHERE identity_id = ?1 AND key_id = 0", + params![&x.to_buffer()[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + surviving, 1, + "a foreign wallet's removed set must not delete the owner's key" + ); + + // ...and the owner can still delete its own. + { + let tx = conn.transaction().unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &owner, &removal).unwrap(); + tx.commit().unwrap(); + } + let after_owner: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys WHERE identity_id = ?1 AND key_id = 0", + params![&x.to_buffer()[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + after_owner, 0, + "the owning wallet must still be able to delete" + ); + } + + /// The converse of the NULL-scope trigger, and the reason no third + /// trigger is needed: a WALLET-scoped key naming an UNOWNED identity. + /// + /// Here the child key `(wallet_id, identity_id)` is fully non-NULL, so + /// MATCH SIMPLE leaves the compound FK live and it rejects the write + /// on its own. Asserting the extended code keeps that claim honest — + /// 787 proves the FK did it, rather than the trigger or one of the + /// earlier in-Rust guards quietly covering for an unenforced FK. + #[test] + fn wallet_scoped_key_is_rejected_for_an_unowned_identity() { + use platform_wallet::changeset::IdentityKeysChangeSet; + + let mut conn = migrated_conn(); + let a = [0xB7u8; 32]; + let unowned = [0u8; 32]; + insert_wallet(&conn, &a); + + // Identity Z belongs to no wallet. + let z = Identifier::from([0xB8u8; 32]); + let mut ids = IdentityChangeSet::default(); + ids.identities.insert(z, entry([0xB8; 32], None, 40, None)); + apply_in_tx(&mut conn, &unowned, &ids); + + // Wallet A files a key for it under its own scope. + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((z, 0), sample_key_entry(z, 0xB9)); + let tx = conn.transaction().unwrap(); + let err = crate::sqlite::schema::identity_keys::apply(&tx, &a, &keys) + .expect_err("a wallet may not file a key for an identity it does not own"); + assert!( + matches!( + err, + WalletStorageError::IdentityKeyWalletMismatch { + wallet_id, identity_id, .. + } if wallet_id == a && identity_id == z.to_buffer() + ), + "expected IdentityKeyWalletMismatch naming wallet A and identity Z, got {err:?}" + ); + match &err { + WalletStorageError::IdentityKeyWalletMismatch { source, .. } => match source.as_ref() { + rusqlite::Error::SqliteFailure(e, _) => assert_eq!( + e.extended_code, 787, + "this case must be caught by the live compound FK, not a trigger" + ), + other => panic!("expected a SqliteFailure source, got {other:?}"), + }, + other => panic!("expected IdentityKeyWalletMismatch, got {other:?}"), + } + } + + /// `load_state` rejects a row whose decoded blob names a different + /// `identity_id` than its typed column — corruption is a hard, typed + /// error, never rehydrated under the wrong id. + #[test] + fn load_state_rejects_identity_id_column_mismatch() { + let conn = migrated_conn(); + let a = [0xA1u8; 32]; + insert_wallet(&conn, &a); + let typed_id = [0x01u8; 32]; // column + let blob_id = [0x02u8; 32]; // disagreeing blob + let payload = blob::encode(&entry(blob_id, Some(a), 100, Some(1))).unwrap(); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 1, ?3, 0)", + params![&typed_id[..], &a[..], payload], + ) + .unwrap(); + + let err = load_state(&conn, &a).expect_err("identity_id mismatch must fail"); + assert!( + matches!(err, WalletStorageError::IdentityEntryIdMismatch), + "expected IdentityEntryIdMismatch, got {err:?}" + ); + } +} 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 index 78cb72c2338..4b75d740267 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -1,23 +1,17 @@ //! `identity_keys` table writer. Stores PUBLIC key material only — no -//! signing-key bytes ever reach this table. +//! signing-key bytes reach this table. //! -//! `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}; +//! `IdentityKeyEntry.public_key`'s `#[serde(tag = ...)]` enum is rejected by +//! bincode-serde (needs `deserialize_any`), so `IdentityKeyWire` pre-encodes +//! the key with bincode's native `Encode`/`Decode` and rides the surrounding +//! fields on the serde encoder, keeping one blob per row. + +use rusqlite::{params, Connection, Transaction}; use serde::{Deserialize, Serialize}; -use dpp::identity::KeyID; -// Used only by the test-gated `into_entry` and the unit tests below. -#[cfg(any(test, feature = "__test-helpers"))] +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::IdentityPublicKey; +use dpp::identity::KeyID; use dpp::prelude::Identifier; use platform_wallet::changeset::{ IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, @@ -25,12 +19,11 @@ use platform_wallet::changeset::{ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; 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. +/// On-disk wire shape for `IdentityKeyEntry`, with `public_key_bincode` +/// holding the natively-encoded key (see module docs). #[derive(Debug, Clone, Serialize, Deserialize)] struct IdentityKeyWire { identity_id: Identifier, @@ -41,9 +34,13 @@ struct IdentityKeyWire { derivation_indices: Option, } +// PUBLIC material only reaching `entry_blob`: the wire shape carries +// bincode-encoded public keys + public-key hashes. No private bytes. +crate::sqlite::schema::blob::impl_persistable_blob!(IdentityKeyWire); + impl IdentityKeyWire { fn from_entry(entry: &IdentityKeyEntry) -> Result { - let pk = bincode::encode_to_vec(&entry.public_key, bincode::config::standard())?; + let pk = bincode::encode_to_vec(&entry.public_key, blob::bounded_config())?; Ok(Self { identity_id: entry.identity_id, key_id: entry.key_id, @@ -54,14 +51,11 @@ impl IdentityKeyWire { }) } - #[cfg(any(test, feature = "__test-helpers"))] fn into_entry(self) -> Result { let (public_key, consumed): (IdentityPublicKey, usize) = - bincode::decode_from_slice(&self.public_key_bincode, bincode::config::standard())?; - // 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 - // or forward-schema drift — refuse it. + bincode::decode_from_slice(&self.public_key_bincode, blob::bounded_config())?; + // Reject a valid-prefix + trailing-garbage payload (bincode stops + // after the typed length); mirrors the outer blob::decode guard. if consumed != self.public_key_bincode.len() { return Err(WalletStorageError::blob_decode( "unexpected trailing bytes in identity_keys.public_key_bincode", @@ -78,73 +72,266 @@ impl IdentityKeyWire { } } -/// `identity_keys` is keyed by `(identity_id, key_id)`; the parent FK -/// targets `identities(identity_id)`. The caller-supplied [`WalletId`] -/// scopes cross-checks against the entry's own `wallet_id` field so -/// the entry-blob and the typed columns stay aligned. +/// SQLite's extended result code for a foreign-key constraint failure +/// (`SQLITE_CONSTRAINT_FOREIGNKEY`). Discriminated numerically — never by +/// matching the driver's message text. +const SQLITE_CONSTRAINT_FOREIGNKEY: i32 = 787; + +/// SQLite's extended result code for a `RAISE(ABORT)` raised inside a +/// trigger (`SQLITE_CONSTRAINT_TRIGGER`). The NULL-scope guard is a +/// trigger rather than an FK, so it surfaces under this code, not 787. +const SQLITE_CONSTRAINT_TRIGGER: i32 = 1811; + +/// Re-map a co-ownership constraint violation on the `identity_keys` +/// upsert to a typed error naming the wallet and identity involved. +/// +/// The table has two FK parents, so a violation could in principle be the +/// missing-`wallets` one; the compound `identities(wallet_id, identity_id)` +/// parent is by far the likelier cause and the one worth naming, since a +/// cross-wallet key write is the failure this guard exists to catch. +/// +/// Two extended codes reach the same conclusion. A wallet-scoped write is +/// caught by the compound FK (`_FOREIGNKEY`); a NULL-scoped write, for +/// which MATCH SIMPLE leaves that FK dormant, is caught instead by the +/// `RAISE(ABORT)` trigger pair (`_TRIGGER`). Both mean "this key does not +/// belong under this scope", so both fold into one variant rather than +/// splitting a single invariant across two errors an operator would have +/// to learn to treat identically. +fn map_fk_violation( + err: rusqlite::Error, + wallet_id: &WalletId, + identity_id: &Identifier, +) -> WalletStorageError { + match &err { + rusqlite::Error::SqliteFailure(e, _) + if e.code == rusqlite::ErrorCode::ConstraintViolation + && (e.extended_code == SQLITE_CONSTRAINT_FOREIGNKEY + || e.extended_code == SQLITE_CONSTRAINT_TRIGGER) => + { + WalletStorageError::IdentityKeyWalletMismatch { + wallet_id: *wallet_id, + identity_id: identity_id.to_buffer(), + source: Box::new(err), + } + } + _ => WalletStorageError::from(err), + } +} + +/// Keyed by `(identity_id, key_id)` — matching the domain changeset, since +/// an identity has exactly one owning wallet — with an FK to `wallets` and a +/// compound FK to `identities(wallet_id, identity_id)` — so a key can only be +/// filed under the wallet that owns the identity. The typed `wallet_id` column +/// comes from the flush scope; the entry's own `wallet_id` (when set) is +/// cross-checked against it so the typed columns and the blob stay aligned. +/// A cross-wallet write is refused with +/// [`WalletStorageError::IdentityKeyWalletMismatch`]. pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, cs: &IdentityKeysChangeSet, ) -> Result<(), WalletStorageError> { if !cs.upserts.is_empty() { + // `derivation_blob` is always NULL (reserved); derivation_indices ride + // inside the IdentityKeyWire blob, the source of truth. + // + // `wallet_id = excluded.wallet_id` is load-bearing — do NOT drop it + // as a redundant self-assignment. The primary key is + // `(identity_id, key_id)`, so a foreign wallet writing an existing + // key arrives as a CONFLICT, not an INSERT, and resolves to this + // UPDATE. An UPDATE that leaves `wallet_id` untouched violates no + // foreign key, so without this assignment the compound FK never + // fires on the cross-wallet path — the write returns Ok and the + // foreign wallet's key material silently replaces the owner's + // under the owner's own scope. Assigning the column re-states the + // incoming scope, which is what the FK checks. + // Pinned by `identity_key_upsert_cannot_overwrite_another_wallets_key`. let mut stmt = tx.prepare_cached( "INSERT INTO identity_keys \ - (identity_id, key_id, public_key_blob, public_key_hash) \ - VALUES (?1, ?2, ?3, ?4) \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5, NULL) \ ON CONFLICT(identity_id, key_id) DO UPDATE SET \ public_key_blob = excluded.public_key_blob, \ - public_key_hash = excluded.public_key_hash", + public_key_hash = excluded.public_key_hash, \ + derivation_blob = NULL, \ + wallet_id = excluded.wallet_id", )?; + // The all-zero sentinel scope stores NULL, the same spelling + // `identities` uses for "owned by no wallet". Writing the raw + // 32 zero bytes instead would produce a row that matches no + // wallet, no NULL-scoped reader, and no guard. + let wallet_id_param = super::wallet_id_to_param(wallet_id); 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. + // Typed columns and blob fields must agree so a row can never + // diverge on disk. if entry.identity_id != *identity_id || entry.key_id != *key_id { return Err(WalletStorageError::IdentityKeyEntryMismatch); } - // Sentinel scope ("no parent wallet known") requires the - // entry's wallet_id to also be `None`; a real entry - // wallet_id under sentinel scope would silently file the - // key under the wrong parenting. Non-sentinel scope - // requires the entry's wallet_id (when set) to match - // exactly. - let scope_is_sentinel = wallet_id.iter().all(|b| *b == 0); - match (scope_is_sentinel, entry.wallet_id) { - (true, Some(_)) => return Err(WalletStorageError::IdentityKeyEntryMismatch), - (false, Some(entry_wallet_id)) if entry_wallet_id != *wallet_id => { + if let Some(entry_wallet_id) = entry.wallet_id { + if entry_wallet_id != *wallet_id { return Err(WalletStorageError::IdentityKeyEntryMismatch); } - _ => {} } let wire = IdentityKeyWire::from_entry(entry)?; let entry_blob = blob::encode(&wire)?; stmt.execute(params![ + wallet_id_param, identity_id.as_slice(), i64::from(*key_id), entry_blob, &entry.public_key_hash[..], - ])?; + ]) + .map_err(|e| map_fk_violation(e, wallet_id, identity_id))?; } } if !cs.removed.is_empty() { - let mut stmt = - tx.prepare_cached("DELETE FROM identity_keys WHERE identity_id = ?1 AND key_id = ?2")?; + // `(identity_id, key_id)` alone now identifies the row, so + // `wallet_id = ?1` is no longer part of the key — it is kept + // deliberately as a scope GUARD, mirroring the `identities` + // tombstone: one wallet's `removed` set must not delete another + // wallet's key. Keeping it makes a cross-scope delete a no-op; + // dropping it would make that delete succeed, which is a + // destructive way to be permissive. + // + // NULL-safe `IS` rather than `=` because the column is nullable: + // `wallet_id = NULL` is never true, so a plain `=` could never + // delete an unowned key — the statement would report success and + // remove nothing, which is silent failure, not a guard. `IS` is + // the spelling `identities` already uses for the same reason + // (and, unlike `IS NOT DISTINCT FROM`, needs no SQLite 3.39 + // floor). This keeps the guard and makes it NULL-correct; it + // does not widen what a wallet may delete. + let wallet_id_param = super::wallet_id_to_param(wallet_id); + let mut stmt = tx.prepare_cached( + "DELETE FROM identity_keys \ + WHERE wallet_id IS ?1 AND identity_id = ?2 AND key_id = ?3", + )?; for (identity_id, key_id) in &cs.removed { - stmt.execute(params![identity_id.as_slice(), i64::from(*key_id)])?; + stmt.execute(params![ + wallet_id_param, + identity_id.as_slice(), + i64::from(*key_id), + ])?; } } Ok(()) } /// Decode an `identity_keys.public_key_blob` cell back to the entry. -#[cfg(any(test, feature = "__test-helpers"))] pub fn decode_entry(payload: &[u8]) -> Result { let wire: IdentityKeyWire = blob::decode(payload)?; wire.into_entry() } +/// Read every `identity_keys` row for `wallet_id` back into a keyless +/// [`IdentityKeysChangeSet`] (PUBLIC material only — the blob is an +/// `IdentityPublicKey`; private keys are NOT stored or read here). +/// +/// Keyed by `(identity_id, key_id)`; `removed` is always empty (deletes +/// reach storage as `DELETE`s, never as rows). +/// +/// A row that cannot be read — a blob that fails to decode, or one that +/// contradicts the columns it was selected by — is routed through `ctx`: +/// fatal under [`LoadPolicy::Strict`](crate::LoadPolicy::Strict), skipped +/// and counted at [`LoadSite::IdentityKeyRow`] under `Recovery`. A key +/// carries no funds, so losing one costs a signing option rather than a +/// balance, which is why this row is skippable where a balance-bearing row +/// would have to take its whole wallet down. +/// +/// Structural failures are NOT routed here and stay fatal in both policies: +/// a wrong-width id, an integer that will not narrow, and the blob-size +/// guard, which rejects on the stored length before any buffer exists. +pub fn load_state( + conn: &Connection, + wallet_id: &WalletId, + ctx: &LoadCtx, +) -> Result { + let mut cs = IdentityKeysChangeSet::default(); + // NULL-safe `IS`, the read counterpart of the writer's scope mapping: + // a real wallet id behaves exactly as `=` did, and the all-zero + // sentinel maps to NULL and reads the unowned keys. With a plain `=` + // those keys are unreachable — `wallet_id = NULL` is never true. + let wallet_id_param = super::wallet_id_to_param(wallet_id); + let mut stmt = conn.prepare( + "SELECT identity_id, key_id, length(public_key_blob), public_key_blob, \ + public_key_hash \ + FROM identity_keys WHERE wallet_id IS ?1", + )?; + let mut rows = stmt.query(params![wallet_id_param])?; + while let Some(row) = rows.next()? { + let identity_id_bytes: Vec = row.get(0)?; + let key_id: i64 = row.get(1)?; + blob::check_size(row.get::<_, i64>(2)?)?; + let payload: Vec = row.get(3)?; + let typed_public_key_hash: Vec = row.get(4)?; + let id32 = super::id32("identity_keys.identity_id", &identity_id_bytes)?; + let identity_id = Identifier::from(id32); + let key_id: KeyID = + crate::sqlite::util::safe_cast::i64_to_u32("identity_keys.key_id", key_id)?; + let entry = match decode_entry(&payload) { + Ok(entry) => entry, + Err(err) => { + ctx.tolerate(LoadSite::IdentityKeyRow, err)?; + continue; + } + }; + // Cross-check the decoded blob against the typed columns it was + // selected by (mirrors `accounts`/`asset_locks` readers): a row whose + // blob names a different identity / key / wallet than its indexed + // columns is corruption, never silently mis-keyed into the map. + // `public_key.id()` is verified too — it becomes the DPP + // signing-selection map key via `add_public_key`, so a mismatch would + // file the key under a wrong KeyID rather than being caught here. + // `public_key_hash` is the indexed lookup column while the blob is + // what callers receive, so a divergence makes a key findable under a + // hash it does not carry. + let contradicts_columns = entry.identity_id != identity_id + || entry.key_id != key_id + || entry.public_key.id() != key_id + || entry.public_key_hash[..] != typed_public_key_hash[..] + || entry + .wallet_id + .is_some_and(|entry_wallet_id| entry_wallet_id != *wallet_id); + if contradicts_columns { + ctx.tolerate( + LoadSite::IdentityKeyRow, + WalletStorageError::IdentityKeyEntryMismatch, + )?; + continue; + } + cs.upserts.insert((identity_id, key_id), entry); + } + Ok(cs) +} + +/// Build an outer `public_key_blob` payload whose inner `public_key_bincode` +/// field contains a crafted byte sequence that causes the inner +/// `blob::bounded_config()` decode to fail. Used by the blob-gate integration +/// test to prove the inner decode is bounded end-to-end. +/// +/// The outer blob is well within [`blob::BLOB_SIZE_LIMIT_BYTES`]; +/// only the *inner* decode path is stressed. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn crafted_entry_blob_with_bad_pk_bincode_for_test() -> Vec { + // 0xFC followed by four 0xFF bytes is bincode's 5-byte varint encoding + // for u32::MAX (4 294 967 295). Decoded as the first value inside + // IdentityPublicKey, this either triggers LimitExceeded (4 GB read + // attempt > 16 MiB bound) or an InvalidVariant — either way the decode + // fails without OOM-allocating. + let wire = IdentityKeyWire { + identity_id: dpp::prelude::Identifier::from([0xAAu8; 32]), + key_id: 0, + public_key_bincode: vec![0xFCu8, 0xFF, 0xFF, 0xFF, 0xFF], + public_key_hash: [0u8; 20], + wallet_id: None, + derivation_indices: None, + }; + // Intentionally unbounded outer encode — test setup only, not a + // production path. + bincode::serde::encode_to_vec(&wire, bincode::config::standard()) + .expect("test helper outer encode must not fail") +} + #[cfg(test)] mod tests { use super::*; @@ -152,6 +339,230 @@ mod tests { use dpp::identity::{KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; + /// In-memory connection with the full schema applied. + fn migrated_conn() -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + /// A valid `IdentityPublicKey` for building wire blobs in tests. + fn sample_public_key() -> IdentityPublicKey { + 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, + }) + } + + /// Insert an `identity_keys` row with a fully-formed wire blob, first + /// staging the `wallets` + `identities` FK parents it depends on. + fn insert_key_row( + conn: &Connection, + wallet: &[u8; 32], + typed_identity: &[u8; 32], + wire: &IdentityKeyWire, + ) { + conn.execute( + "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![&wallet[..]], + ) + .unwrap(); + crate::sqlite::schema::identities::ensure_exists(conn, wallet, typed_identity).unwrap(); + let entry_blob = blob::encode(wire).unwrap(); + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, ?3, ?4, NULL)", + params![&wallet[..], &typed_identity[..], entry_blob, &[0u8; 20][..]], + ) + .unwrap(); + } + + /// `load_state`'s `key_id` boundary cast is a `u32` conversion + /// (`KeyID = u32`), so an out-of-`u32`-range column must surface + /// `IntegerOverflow` stamped `SafeCastTarget::U32` — naming `u64` would + /// misdirect an operator to the wrong boundary. + #[test] + fn load_state_key_id_overflow_reports_u32_target() { + let conn = migrated_conn(); + let wallet = [0x77u8; 32]; + let typed_identity = [0x88u8; 32]; + conn.execute( + "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![&wallet[..]], + ) + .unwrap(); + crate::sqlite::schema::identities::ensure_exists(&conn, &wallet, &typed_identity).unwrap(); + let wire = IdentityKeyWire { + identity_id: Identifier::from(typed_identity), + key_id: 0, + public_key_bincode: bincode::encode_to_vec(sample_public_key(), blob::bounded_config()) + .unwrap(), + public_key_hash: [0u8; 20], + wallet_id: None, + derivation_indices: None, + }; + let entry_blob = blob::encode(&wire).unwrap(); + // key_id column set beyond u32::MAX so the i64->u32 cast overflows. + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5, NULL)", + params![ + &wallet[..], + &typed_identity[..], + i64::from(u32::MAX) + 1, + entry_blob, + &[0u8; 20][..] + ], + ) + .unwrap(); + + let err = + load_state(&conn, &wallet, &LoadCtx::strict()).expect_err("key_id overflow must fail"); + assert!( + matches!( + err, + WalletStorageError::IntegerOverflow { + field: "identity_keys.key_id", + target: crate::sqlite::util::safe_cast::SafeCastTarget::U32, + .. + } + ), + "expected IntegerOverflow with U32 target for key_id, got {err:?}" + ); + } + + /// `load_state` rejects a row whose decoded blob names a different + /// `identity_id` than its typed column — corruption is a hard, typed + /// error rather than a silent mis-key into the upsert map. + #[test] + fn load_state_rejects_identity_id_column_mismatch() { + let conn = migrated_conn(); + let wallet = [0x11u8; 32]; + let typed_identity = [0xBBu8; 32]; + let wire = IdentityKeyWire { + identity_id: Identifier::from([0xAAu8; 32]), // disagrees with the column + key_id: 0, + public_key_bincode: bincode::encode_to_vec(sample_public_key(), blob::bounded_config()) + .unwrap(), + public_key_hash: [0u8; 20], + wallet_id: None, + derivation_indices: None, + }; + insert_key_row(&conn, &wallet, &typed_identity, &wire); + + let err = load_state(&conn, &wallet, &LoadCtx::strict()) + .expect_err("identity_id mismatch must fail"); + assert!( + matches!(err, WalletStorageError::IdentityKeyEntryMismatch), + "expected IdentityKeyEntryMismatch, got {err:?}" + ); + } + + /// `load_state` rejects a row whose decoded blob carries a `wallet_id` + /// different from the wallet scope the typed column is read under. + #[test] + fn load_state_rejects_wallet_id_blob_mismatch() { + let conn = migrated_conn(); + let wallet = [0x22u8; 32]; + let typed_identity = [0xCCu8; 32]; + let wire = IdentityKeyWire { + identity_id: Identifier::from(typed_identity), // matches the column + key_id: 0, + public_key_bincode: bincode::encode_to_vec(sample_public_key(), blob::bounded_config()) + .unwrap(), + public_key_hash: [0u8; 20], + wallet_id: Some([0xDDu8; 32]), // disagrees with the read scope + derivation_indices: None, + }; + insert_key_row(&conn, &wallet, &typed_identity, &wire); + + let err = load_state(&conn, &wallet, &LoadCtx::strict()) + .expect_err("wallet_id mismatch must fail"); + assert!( + matches!(err, WalletStorageError::IdentityKeyEntryMismatch), + "expected IdentityKeyEntryMismatch, got {err:?}" + ); + } + + /// `load_state` rejects a row whose typed `public_key_hash` column + /// disagrees with the hash inside the decoded blob. The column is the + /// indexed lookup key while the blob is what callers receive, so a + /// divergence means a key is findable under a hash it does not carry. + #[test] + fn load_state_rejects_public_key_hash_column_mismatch() { + let conn = migrated_conn(); + let wallet = [0x44u8; 32]; + let typed_identity = [0xEEu8; 32]; + let wire = IdentityKeyWire { + identity_id: Identifier::from(typed_identity), + key_id: 0, + public_key_bincode: bincode::encode_to_vec(sample_public_key(), blob::bounded_config()) + .unwrap(), + // `insert_key_row` writes an all-zero hash column, so a non-zero + // blob hash is the drift under test. + public_key_hash: [0x77u8; 20], + wallet_id: None, + derivation_indices: None, + }; + insert_key_row(&conn, &wallet, &typed_identity, &wire); + + let err = load_state(&conn, &wallet, &LoadCtx::strict()) + .expect_err("public_key_hash mismatch must fail"); + assert!( + matches!(err, WalletStorageError::IdentityKeyEntryMismatch), + "expected IdentityKeyEntryMismatch, got {err:?}" + ); + } + + /// `load_state` rejects a row whose inner `IdentityPublicKey.id()` + /// disagrees with the typed `key_id` column. That inner id becomes the + /// DPP signing-selection map key via `add_public_key`, so a mismatch + /// must hard-error, not file the key under the wrong KeyID. + #[test] + fn load_state_rejects_public_key_id_mismatch() { + let conn = migrated_conn(); + let wallet = [0x33u8; 32]; + let typed_identity = [0xEEu8; 32]; + // Inner key carries id=5, but insert_key_row stamps the key_id column 0. + let mismatched_pk = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 5, + 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 wire = IdentityKeyWire { + identity_id: Identifier::from(typed_identity), + key_id: 0, // agrees with the typed column + public_key_bincode: bincode::encode_to_vec(&mismatched_pk, blob::bounded_config()) + .unwrap(), + public_key_hash: [0u8; 20], + wallet_id: None, + derivation_indices: None, + }; + insert_key_row(&conn, &wallet, &typed_identity, &wire); + + let err = load_state(&conn, &wallet, &LoadCtx::strict()) + .expect_err("public_key.id() mismatch must fail"); + assert!( + matches!(err, WalletStorageError::IdentityKeyEntryMismatch), + "expected IdentityKeyEntryMismatch, got {err:?}" + ); + } + /// 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. @@ -167,7 +578,7 @@ mod tests { data: BinaryData::new(vec![2u8; 33]), disabled_at: None, }); - let mut pk_bincode = bincode::encode_to_vec(&pk, bincode::config::standard()).unwrap(); + let mut pk_bincode = bincode::encode_to_vec(&pk, blob::bounded_config()).unwrap(); pk_bincode.push(0xFF); // trailing garbage past the typed length let wire = IdentityKeyWire { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_scan_states.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_scan_states.rs new file mode 100644 index 00000000000..cd2d361bd8a --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_scan_states.rs @@ -0,0 +1,170 @@ +//! `identity_scan_states` writer + reader — the verdict of the last +//! gap-limit identity scan, one row per wallet. +//! +//! The entry is all-primitive apart from its list of unanswered indices, so +//! every field maps to an explicit column and the list lives in the +//! `identity_scan_failed_indices` child table. See +//! [`IdentityScanStateEntry`] for what each field means and why `complete` +//! and `unlocated_gap` are stored rather than derived. + +use rusqlite::{params, Connection, OptionalExtension, Transaction}; + +use platform_wallet::changeset::IdentityScanStateEntry; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; +use crate::sqlite::schema::wallet_id_to_param; +use crate::sqlite::util::safe_cast::i64_to_u32; + +/// Persist `incoming` as this wallet's scan verdict, folded over whatever is +/// already on record. +/// +/// Folding rather than overwriting is what keeps the durable record from +/// losing a gap. In-process the manager has already folded — `superseding` is +/// idempotent against the same previous verdict, so that costs nothing — but +/// one database file may be open to several processes, and a peer holding a +/// staler view would otherwise publish a clean suffix scan over a gap it +/// never probed. That is dashpay/platform#4365 reached from the other side, +/// and it is the one direction this row must never move in. +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + incoming: &IdentityScanStateEntry, +) -> Result<(), WalletStorageError> { + let folded = match read(tx, wallet_id)? { + Some(previous) => incoming.clone().superseding(&previous), + None => incoming.clone(), + }; + + // Named so a field added to `IdentityScanStateEntry` is a compile error + // here rather than a column that silently stops being written. + let IdentityScanStateEntry { + complete, + probed_from, + probed_through, + ref failed_indices, + unlocated_gap, + } = folded; + + tx.prepare_cached( + "INSERT INTO identity_scan_states \ + (wallet_id, complete, probed_from, probed_through, unlocated_gap) \ + VALUES (?1, ?2, ?3, ?4, ?5) \ + ON CONFLICT(wallet_id) DO UPDATE SET \ + complete = excluded.complete, \ + probed_from = excluded.probed_from, \ + probed_through = excluded.probed_through, \ + unlocated_gap = excluded.unlocated_gap", + )? + .execute(params![ + wallet_id.as_slice(), + i64::from(complete), + i64::from(probed_from), + i64::from(probed_through), + i64::from(unlocated_gap), + ])?; + + // The list is replaced wholesale: `folded` already carries every index + // still outstanding, so a survivor of the old row that is missing here + // has been answered. + tx.prepare_cached("DELETE FROM identity_scan_failed_indices WHERE wallet_id = ?1")? + .execute(params![wallet_id.as_slice()])?; + let mut insert = tx.prepare_cached( + "INSERT INTO identity_scan_failed_indices (wallet_id, failed_index) VALUES (?1, ?2)", + )?; + for index in failed_indices { + insert.execute(params![wallet_id.as_slice(), i64::from(*index)])?; + } + Ok(()) +} + +/// This wallet's scan verdict, or `None` when no scan has ever published one. +/// +/// Absence is deliberately not completeness: upstream reads a missing entry +/// as "keep the existing warm-launch behaviour", so a wallet that predates +/// this bookkeeping is left exactly where it was. +/// +/// # Errors +/// +/// Returns [`WalletStorageError::IdentityScanStateContradiction`] under +/// [`LoadPolicy::Strict`](crate::sqlite::config::LoadPolicy::Strict) when the +/// row claims a complete scan while unanswered indices sit beside it — a +/// state no fold can produce. Under +/// [`LoadPolicy::Recovery`](crate::sqlite::config::LoadPolicy::Recovery) the +/// contradiction is counted and the verdict clamped to incomplete: the cost +/// of being wrong that way is one extra scan, and the cost of being wrong the +/// other way is an identity that never reappears. +pub fn load_for_wallet( + conn: &Connection, + wallet_id: &WalletId, + ctx: &LoadCtx, +) -> Result, WalletStorageError> { + let Some(mut entry) = read(conn, wallet_id)? else { + return Ok(None); + }; + if entry.complete && !entry.failed_indices.is_empty() { + ctx.tolerate( + LoadSite::IdentityScanStateContradiction, + WalletStorageError::IdentityScanStateContradiction { + wallet_id: *wallet_id, + failed_indices: entry.failed_indices.len(), + }, + )?; + entry.complete = false; + } + Ok(Some(entry)) +} + +/// Read the row and its indices verbatim, without judging them. Shared by the +/// write-side fold (which must see exactly what is stored) and the read path +/// (which validates on top). +/// +/// Takes `&Connection` so a `Transaction`'s deref covers the writer too. +fn read( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + // NULL-safe `IS`, matching the identity readers: the all-zero unowned + // sentinel maps to NULL, which a NOT NULL primary key can never hold, so + // the unowned scope correctly finds nothing instead of matching by luck. + let wallet_id_param = wallet_id_to_param(wallet_id); + let row = conn + .prepare_cached( + "SELECT complete, probed_from, probed_through, unlocated_gap \ + FROM identity_scan_states WHERE wallet_id IS ?1", + )? + .query_row(params![wallet_id_param], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .optional()?; + let Some((complete, probed_from, probed_through, unlocated_gap)) = row else { + return Ok(None); + }; + + let mut stmt = conn.prepare_cached( + "SELECT failed_index FROM identity_scan_failed_indices \ + WHERE wallet_id IS ?1 ORDER BY failed_index", + )?; + let mut rows = stmt.query(params![wallet_id_param])?; + let mut failed_indices = Vec::new(); + while let Some(row) = rows.next()? { + failed_indices.push(i64_to_u32( + "identity_scan_failed_indices.failed_index", + row.get(0)?, + )?); + } + + Ok(Some(IdentityScanStateEntry { + complete: complete != 0, + probed_from: i64_to_u32("identity_scan_states.probed_from", probed_from)?, + probed_through: i64_to_u32("identity_scan_states.probed_through", probed_through)?, + failed_indices, + unlocated_gap: unlocated_gap != 0, + })) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs index 527870e1691..16f0c86138d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/invitations.rs @@ -153,9 +153,9 @@ mod tests { let wallet_id: WalletId = [0x11; 32]; let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); - // The FK requires the wallet_metadata row to exist. + // The FK requires the wallets row to exist. conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![&wallet_id[..]], ) .unwrap(); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index b545be7a9af..88bff87e377 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -1,43 +1,65 @@ -//! Per-area SQLite writers + readers. +//! Per-area SQLite writers + readers, one submodule per table or cluster. //! -//! Each submodule owns one table or a small cluster (e.g. `accounts` -//! owns the registration + address-pool tables). 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. +//! Encoding policy: scalars that fan out to per-row indexes go into typed +//! columns (heights, hashes, outpoints, flags); `_blob` columns carry the +//! full sub-changeset entry via [`blob::encode`] / [`blob::decode`]. Schema +//! evolution is gated by the refinery migration version — blobs carry no +//! inline revision tag. pub mod accounts; pub mod asset_locks; pub mod blob; pub mod contacts; +pub mod core_pool; pub mod core_state; pub mod dashpay; pub mod dpns_name_states; pub mod identities; pub mod identity_keys; +pub mod identity_scan_states; pub mod invitations; pub mod pending_contact_crypto; pub mod platform_addrs; +#[cfg(feature = "shielded")] +pub mod shielded_viewing_keys; pub mod token_balances; pub mod tracked_masternodes; -pub mod wallet_meta; +pub mod versions; +pub mod wallets; -/// Defensive check that every `identity_id` in `touched` exists in -/// `identities` and belongs to `wallet_id` (or has NULL wallet_id when -/// scope is the all-zero sentinel). Used by identity-owned writers -/// (`dashpay`, `token_balances`) to reject mis-attributed callers; the -/// check runs in every build. +/// Map a `WalletId` to a nullable `wallet_id` column: the all-zero +/// sentinel becomes NULL, the storage spelling of "owned by no wallet". /// -/// Returns [`WalletStorageError::WalletIdMismatch`] for the first -/// offending row found. Rows that don't exist in `identities` aren't -/// flagged here — the FK on the child table will reject the write. +/// Shared by `identities` and `identity_keys` so both spell the unowned +/// scope the same way — a raw `wallet_id.as_slice()` would store 32 zero +/// bytes, a value that looks like a wallet id, satisfies nothing, and +/// silently fails to match the NULL the readers and guards look for. +pub(crate) fn wallet_id_to_param( + wallet_id: &platform_wallet::wallet::platform_wallet::WalletId, +) -> Option<&[u8]> { + if wallet_id.iter().all(|b| *b == 0) { + None + } else { + Some(wallet_id.as_slice()) + } +} + +pub(crate) fn id32( + column: &'static str, + bytes: &[u8], +) -> Result<[u8; 32], crate::sqlite::error::WalletStorageError> { + <[u8; 32]>::try_from(bytes).map_err(|_| { + crate::sqlite::error::WalletStorageError::InvalidWalletIdLength { + column, + actual: bytes.len(), + } + }) +} + +/// Reject any `identity_id` in `touched` whose `identities` row does not +/// belong to `wallet_id` (NULL wallet_id matches the all-zero sentinel), +/// returning [`WalletStorageError::WalletIdMismatch`] on the first offender. +/// Absent rows are left to the child-table FK. pub(crate) fn assert_identities_belong_to_wallet( tx: &rusqlite::Transaction<'_>, wallet_id: &platform_wallet::wallet::platform_wallet::WalletId, @@ -52,15 +74,11 @@ pub(crate) fn assert_identities_belong_to_wallet( .query_row(rusqlite::params![identity_id.as_slice()], |row| row.get(0)) .optional()?; let Some(found_wallet_id) = row else { - // Row absent — FK on the child table will reject the - // upcoming write with a clearer error than guessing. + // Row absent — let the child-table FK reject the write. continue; }; - // INTENTIONAL: the `Some(found)` arms below zero-pad a stored - // wallet_id whose width is not 32 into the diagnostic `found` field. - // This is diagnostic-only and cosmetic — a malformed stored width - // already triggers a mismatch error; reporting it zero-padded carries - // no security impact, so a typed length error is not warranted. + // INTENTIONAL: arms below zero-pad a non-32-byte stored wallet_id into + // the diagnostic `found` field — cosmetic only, a mismatch still errors. match (scope_is_sentinel, found_wallet_id) { (true, None) => {} // sentinel scope matches NULL parenting (true, Some(found)) => { @@ -95,3 +113,21 @@ pub(crate) fn assert_identities_belong_to_wallet( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::id32; + use crate::sqlite::error::WalletStorageError; + + #[test] + fn id32_reports_column_and_actual_length() { + let error = id32("example.owner_id", &[0u8; 7]).unwrap_err(); + assert!(matches!( + error, + WalletStorageError::InvalidWalletIdLength { + column: "example.owner_id", + actual: 7 + } + )); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs index 51fba47e8a2..469654d22dc 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs @@ -24,10 +24,17 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; -/// TEXT-column domain for `pending_contact_crypto.kind`. Single source of truth -/// shared with the migration's CHECK clause and [`kind_db_label`]; pinned equal -/// to the writer's codomain by `kind_labels_match_enum`. -pub const KIND_LABELS: &[&str] = &[ +// PUBLIC material only: ciphertext + public-key indices, never private bytes. +crate::sqlite::schema::blob::impl_persistable_blob!(PendingContactCrypto); + +/// TEXT-column domain for `pending_contact_crypto.kind`. The migrations +/// interpolate nothing: V001 freezes its own copy of this domain, because a +/// generated-SQL change breaks that migration's Refinery checksum on every +/// database that already applied it. Pinned equal to the writer's codomain by +/// `kind_labels_match_enum`, and to V001's frozen list by +/// `kind_labels_frozen_in_v001`. +#[cfg(test)] +pub(crate) const KIND_LABELS: &[&str] = &[ "register_receiving", "register_external", "contact_info_decrypt", @@ -64,7 +71,10 @@ pub fn apply_pending_contact_crypto( let payload = blob::encode(entry)?; let owner = entry.owner_identity_id.to_buffer(); let contact = entry.contact_id.to_buffer(); - let enqueued = i64::try_from(entry.enqueued_at_ms).unwrap_or(i64::MAX); + let enqueued = crate::sqlite::util::safe_cast::u64_to_i64( + "pending_contact_crypto.enqueued_at_ms", + entry.enqueued_at_ms, + )?; stmt.execute(params![ wallet_id.as_slice(), owner.as_slice(), @@ -97,16 +107,22 @@ pub fn apply_pending_contact_crypto( /// Every wallet's deferred-crypto queue, grouped by `wallet_id`, decoded from /// the `payload` blob. /// -/// The production consumer is the `load()` restore into each identity's -/// each identity's `DashPayState.pending_contact_crypto`, fanned out by `owner_identity_id` -/// (this reader returns entries grouped by `wallet_id`; the restore must apply -/// the wallet's identities BEFORE routing each entry to its owner's queue, or an -/// entry whose owner isn't resident yet is dropped). It is blocked on the -/// upstream per-wallet state restore (`LOAD_UNIMPLEMENTED: ClientStartState::wallets` -/// — see `persister.rs`). Until that lands this reader is exercised only by the -/// round-trip test, so it is `cfg(test)`-gated to keep both the lib and the -/// `__test-helpers` builds dead-code-clean; widen to -/// `any(test, feature = "__test-helpers")` when the load restore consumes it. +/// **Nothing on the production path calls this.** `load()` does not restore +/// the queue, so a restart abandons whatever it holds — the table is listed +/// in `LOAD_UNIMPLEMENTED` (see `persister.rs`) so the abandoned rows are at +/// least counted on `LoadDegradation` rather than reported as none. +/// +/// The precondition once cited here — an upstream per-wallet state restore — +/// is met: `load()` rebuilds a full `ClientWalletStartState`. What remains is +/// a decision nobody has taken, not a blocker. The consumer would be each +/// identity's `DashPayState.pending_contact_crypto`, fanned out by +/// `owner_identity_id`; this reader groups by `wallet_id`, so a restore must +/// make the wallet's identities resident BEFORE routing entries, or an entry +/// whose owner is not yet loaded is silently dropped. +/// +/// `cfg(test)`-gated to keep the lib and `__test-helpers` builds +/// dead-code-clean; widen to `any(test, feature = "__test-helpers")` when a +/// production consumer exists. #[cfg(test)] pub(crate) fn all_pending_contact_crypto( conn: &Connection, @@ -121,11 +137,7 @@ pub(crate) fn all_pending_contact_crypto( let mut out: BTreeMap> = BTreeMap::new(); for r in rows { let (wid_bytes, payload) = r?; - let wallet_id = <[u8; 32]>::try_from(wid_bytes.as_slice()).map_err(|_| { - WalletStorageError::InvalidWalletIdLength { - actual: wid_bytes.len(), - } - })?; + let wallet_id = super::id32("pending_contact_crypto.wallet_id", &wid_bytes)?; let entry: PendingContactCrypto = blob::decode(&payload)?; out.entry(wallet_id).or_default().push(entry); } @@ -157,13 +169,71 @@ mod tests { ); } + /// Pins the live domain to the list frozen in `V001__initial.rs`. + /// + /// IF THIS FAILS: do NOT edit V001's list to match. Refinery checksums a + /// migration's rendered SQL, so changing an applied migration's body makes + /// every database that already ran it fail to open, permanently. Append a + /// migration rebuilding the table with the widened CHECK (the + /// `V004__asset_lock_recovered_status.rs` pattern), then update this pin. + #[test] + fn kind_labels_frozen_in_v001() { + assert_eq!( + KIND_LABELS, + &[ + "register_receiving", + "register_external", + "contact_info_decrypt", + "auto_accept", + ] + ); + } + + /// `enqueued_at_ms` past `i64::MAX` must surface a typed + /// `IntegerOverflow` — consistent with every other durable u64→i64 cast + /// in this subtree — not silently clamp to `i64::MAX` and persist a + /// falsified timestamp. + #[test] + fn enqueued_at_ms_overflow_is_typed_error_not_silent_clamp() { + use crate::sqlite::migrations; + use crate::sqlite::schema::wallets; + use dpp::prelude::Identifier; + use platform_wallet::changeset::PendingContactCryptoOp; + use rusqlite::Connection; + + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id: WalletId = [8u8; 32]; + wallets::ensure_exists(&conn, &wallet_id).unwrap(); + + let entry = PendingContactCrypto { + owner_identity_id: Identifier::from([0xAAu8; 32]), + contact_id: Identifier::from([0xBBu8; 32]), + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: u64::MAX, + }; + let tx = conn.transaction().unwrap(); + let err = apply_pending_contact_crypto(&tx, &wallet_id, std::slice::from_ref(&entry), &[]) + .expect_err("enqueued_at_ms past i64::MAX must error, not clamp"); + assert!( + matches!( + err, + WalletStorageError::IntegerOverflow { + field: "pending_contact_crypto.enqueued_at_ms", + .. + } + ), + "expected IntegerOverflow for enqueued_at_ms, got {err:?}" + ); + } + /// Persist two queue entries, read them back, clear one, read again — /// proving the add-delta upsert, the keyed clear-delta delete, and the /// payload round-trip all work against the real migrated schema. #[test] fn round_trip_persists_and_clears_queue() { use crate::sqlite::migrations; - use crate::sqlite::schema::wallet_meta; + use crate::sqlite::schema::wallets; use dpp::prelude::Identifier; use platform_wallet::changeset::PendingContactCryptoOp; use rusqlite::Connection; @@ -171,7 +241,7 @@ mod tests { let mut conn = Connection::open_in_memory().unwrap(); migrations::run(&mut conn).unwrap(); let wallet_id: WalletId = [7u8; 32]; - wallet_meta::ensure_exists(&conn, &wallet_id).unwrap(); + wallets::ensure_exists(&conn, &wallet_id).unwrap(); let owner = Identifier::from([0xAAu8; 32]); let contact = Identifier::from([0xBBu8; 32]); 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 index 5b3d6998d40..3d8e777e2d7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs @@ -14,6 +14,7 @@ use platform_wallet::wallet::{PerAccountPlatformAddressState, PerWalletPlatformA use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::accounts; +use crate::sqlite::schema::blob; use crate::sqlite::util::safe_cast; pub fn apply( @@ -251,49 +252,81 @@ pub fn count_per_wallet( /// `address_row_count` is the number of `platform_addresses` rows for the /// wallet — `load()` uses it (with the watermark and per_account) to /// decide whether the wallet carries any platform state worth surfacing. +/// That makes a silently skipped row change a DECISION, not just data, +/// which is half of why a failure here costs the whole wallet. pub type LoadAllEntry = (PlatformAddressSyncStartState, usize); +/// Per-wallet scan outcome: a wallet's rows, or the first failure met in +/// them. +/// +/// `platform_addresses` carries `balance`, so a skipped row would quietly +/// lower a reported balance. The unit of loss is therefore the whole wallet: +/// either every one of its rows was read, or none of its state is offered. +type PerWallet = BTreeMap>; + +/// Record `err` against `wallet_id`, keeping the FIRST failure — a later row +/// cannot explain the wallet's loss better than the row that caused it. +fn fail_wallet(out: &mut PerWallet, wallet_id: WalletId, err: WalletStorageError) { + match out.entry(wallet_id) { + std::collections::btree_map::Entry::Occupied(mut slot) => { + if slot.get().is_ok() { + *slot.get_mut() = Err(err); + } + } + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(Err(err)); + } + } +} + /// Bulk reader for `load()`. Cost is a fixed number of grouped scans — /// one over `platform_address_sync`, one over `platform_addresses`, and /// one over the `platform_payment` `account_registrations` — regardless /// of wallet count, rather than a per-wallet fan-out. /// -/// Driven by [`wallet_meta::list_ids`](crate::sqlite::schema::wallet_meta::list_ids): +/// Driven by [`wallets::list_ids`](crate::sqlite::schema::wallets::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> { - let sync_by_wallet = all_sync_state(conn)?; - let addresses_by_wallet = all_address_rows(conn)?; - let registrations_by_wallet = accounts::all_platform_payment_registrations(conn)?; - - let empty_rows: Vec = Vec::new(); - let empty_regs: Vec = Vec::new(); +pub fn load_all(conn: &Connection) -> Result, WalletStorageError> { + let mut sync_by_wallet = all_sync_state(conn)?; + let mut addresses_by_wallet = all_address_rows(conn)?; + let mut registrations_by_wallet = accounts::all_platform_payment_registrations(conn)?; - let mut out: BTreeMap = BTreeMap::new(); - for wallet_id in crate::sqlite::schema::wallet_meta::list_ids(conn)? { - let (h, t, r) = sync_by_wallet.get(&wallet_id).copied().unwrap_or((0, 0, 0)); - let address_rows = addresses_by_wallet.get(&wallet_id).unwrap_or(&empty_rows); + let mut out: PerWallet = BTreeMap::new(); + for wallet_id in crate::sqlite::schema::wallets::list_ids(conn)? { + // A wallet with no rows in a table is not a failure: it maps to the + // same empty defaults it always did. + let sync = sync_by_wallet.remove(&wallet_id).unwrap_or(Ok((0, 0, 0))); + let address_rows = addresses_by_wallet + .remove(&wallet_id) + .unwrap_or(Ok(Vec::new())); let registrations = registrations_by_wallet - .get(&wallet_id) - .unwrap_or(&empty_regs); - let sync = PlatformAddressSyncStartState { - per_account: build_per_account(registrations, address_rows), - sync_height: h, - sync_timestamp: t, - last_known_recent_block: r, + .remove(&wallet_id) + .unwrap_or(Ok(Vec::new())); + + let entry = match (sync, address_rows, registrations) { + (Ok((h, t, r)), Ok(address_rows), Ok(registrations)) => Ok(( + PlatformAddressSyncStartState { + per_account: build_per_account(®istrations, &address_rows), + sync_height: h, + sync_timestamp: t, + last_known_recent_block: r, + }, + address_rows.len(), + )), + // First failure across the three scans, in scan order. + (Err(err), _, _) | (_, Err(err), _) | (_, _, Err(err)) => Err(err), }; - out.insert(wallet_id, (sync, address_rows.len())); + out.insert(wallet_id, entry); } Ok(out) } /// One grouped scan of `platform_address_sync` → `(sync_height, /// sync_timestamp, last_known_recent_block)` per wallet. -fn all_sync_state( - conn: &Connection, -) -> Result, WalletStorageError> { +fn all_sync_state(conn: &Connection) -> Result, WalletStorageError> { let mut stmt = conn.prepare( "SELECT wallet_id, sync_height, sync_timestamp, last_known_recent_block \ FROM platform_address_sync", @@ -306,18 +339,25 @@ fn all_sync_state( row.get::<_, i64>(3)?, )) })?; - let mut out: BTreeMap = BTreeMap::new(); + let mut out: PerWallet<(u64, u64, u64)> = BTreeMap::new(); for r in rows { let (wid_bytes, h, t, recent) = r?; + // A wallet id that is not 32 bytes belongs to no wallet, so there is + // nobody to attribute it to: it stays file-fatal, like `list_ids`. let wallet_id = wallet_id_from_bytes(&wid_bytes)?; - out.insert( - wallet_id, - ( + let watermarks = (|| { + Ok(( 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", recent)?, - ), - ); + )) + })(); + match watermarks { + Ok(watermarks) => { + out.insert(wallet_id, Ok(watermarks)); + } + Err(err) => fail_wallet(&mut out, wallet_id, err), + } } Ok(out) } @@ -326,36 +366,52 @@ fn all_sync_state( /// wallet, ordered for stable per-account grouping. fn all_address_rows( conn: &Connection, -) -> Result>, WalletStorageError> { +) -> Result>, WalletStorageError> { + // length(address) is read first (O(1)) so an oversize or wrong-width + // address blob is caught before materializing the Vec. let mut stmt = conn.prepare( - "SELECT wallet_id, account_index, address_index, address, balance, nonce, \ + "SELECT wallet_id, account_index, address_index, length(address), address, balance, nonce, \ as_of_height \ FROM platform_addresses ORDER BY wallet_id, account_index, address_index, address", )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, Vec>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, Vec>(3)?, - row.get::<_, i64>(4)?, - row.get::<_, i64>(5)?, - row.get::<_, i64>(6)?, - )) - })?; - let mut out: BTreeMap> = BTreeMap::new(); - for r in rows { - let (wid_bytes, account_index, address_index, address_bytes, balance, nonce, as_of_height) = - r?; + let mut rows = stmt.query([])?; + let mut out: PerWallet> = BTreeMap::new(); + while let Some(row) = rows.next()? { + let wid_bytes: Vec = row.get(0)?; + let account_index: i64 = row.get(1)?; + let address_index: i64 = row.get(2)?; + let address_width: i64 = row.get(3)?; + let address_bytes: Vec = row.get(4)?; + let balance: i64 = row.get(5)?; + let nonce: i64 = row.get(6)?; + let as_of_height: i64 = row.get(7)?; + // Same rule as the sync scan: an unattributable id stays file-fatal. let wallet_id = wallet_id_from_bytes(&wid_bytes)?; - out.entry(wallet_id).or_default().push(decode_address_row( - account_index, - address_index, - &address_bytes, - balance, - nonce, - as_of_height, - )?); + let decoded = blob::check_fixed_width( + address_width, + 20, + "platform_addresses.address is not 20 bytes", + ) + .and_then(|()| { + decode_address_row( + account_index, + address_index, + &address_bytes, + balance, + nonce, + as_of_height, + ) + }); + match decoded { + // A wallet already recorded as failed keeps its first cause; + // its remaining rows cannot change the outcome. + Ok(decoded) => { + if let Ok(rows) = out.entry(wallet_id).or_insert_with(|| Ok(Vec::new())) { + rows.push(decoded); + } + } + Err(err) => fail_wallet(&mut out, wallet_id, err), + } } Ok(out) } @@ -378,23 +434,9 @@ fn decode_address_row( hash160.copy_from_slice(address_bytes); let balance = safe_cast::i64_to_u64("platform_addresses.balance", balance)?; let as_of_height = safe_cast::i64_to_u64("platform_addresses.as_of_height", as_of_height)?; - let nonce = u32::try_from(nonce).map_err(|_| WalletStorageError::IntegerOverflow { - field: "platform_addresses.nonce", - value: nonce as u64, - target: 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: 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: safe_cast::SafeCastTarget::U64, - })?; + let nonce = safe_cast::i64_to_u32("platform_addresses.nonce", nonce)?; + let account_index = safe_cast::i64_to_u32("platform_addresses.account_index", account_index)?; + let address_index = safe_cast::i64_to_u32("platform_addresses.address_index", address_index)?; Ok(PlatformAddressRow { account_index, address_index, @@ -408,7 +450,47 @@ fn decode_address_row( } fn wallet_id_from_bytes(bytes: &[u8]) -> Result { - <[u8; 32]>::try_from(bytes).map_err(|_| WalletStorageError::InvalidWalletIdLength { - actual: bytes.len(), - }) + super::id32("platform_addresses.wallet_id", bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `decode_address_row`'s three `u32` boundary casts (nonce, + /// account_index, address_index) must stamp `SafeCastTarget::U32`: each + /// is a `u32::try_from`, so a corrupt row that overflows `u32` overflows + /// *that* type — the diagnostic must name it, not `u64`, or an operator + /// is misdirected to the wrong boundary. + #[test] + fn decode_address_row_overflow_reports_u32_target() { + let over = i64::from(u32::MAX) + 1; + let addr = [0u8; 20]; + for (label, err) in [ + ( + "platform_addresses.nonce", + decode_address_row(0, 0, &addr, 0, over, 0).unwrap_err(), + ), + ( + "platform_addresses.account_index", + decode_address_row(over, 0, &addr, 0, 0, 0).unwrap_err(), + ), + ( + "platform_addresses.address_index", + decode_address_row(0, over, &addr, 0, 0, 0).unwrap_err(), + ), + ] { + match err { + WalletStorageError::IntegerOverflow { field, target, .. } => { + assert_eq!(field, label, "field must name the overflowing column"); + assert_eq!( + target, + safe_cast::SafeCastTarget::U32, + "{label} is a u32 cast; target must be U32, not U64" + ); + } + other => panic!("expected IntegerOverflow for {label}, got {other:?}"), + } + } + } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/shielded_viewing_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/shielded_viewing_keys.rs new file mode 100644 index 00000000000..5f4c54b8cde --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/shielded_viewing_keys.rs @@ -0,0 +1,156 @@ +//! Native persistence for per-subwallet Orchard full viewing keys. + +use std::collections::BTreeMap; + +use platform_wallet::changeset::ShieldedChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::wallet::shielded::SubwalletId; +use rusqlite::{params, Connection, Transaction}; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite, SiteCoords}; +use crate::sqlite::schema::{blob, id32}; + +const VIEWING_KEY_WIDTH: usize = 96; + +fn tolerate_skipped_row( + ctx: &LoadCtx, + error: WalletStorageError, + row_number: usize, + wallet_id: Option<&[u8]>, + account_index: Option, +) -> Result<(), WalletStorageError> { + let wallet_id = wallet_id.and_then(|bytes| bytes.try_into().ok()); + let detail = (row_number, account_index); + ctx.tolerate_at( + LoadSite::ShieldedViewingKeyRow, + SiteCoords { + wallet_id, + account_type: &"shielded_viewing_key", + affected: 1, + detail: Some(&detail), + }, + error, + ) +} + +pub(crate) fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + changeset: &ShieldedChangeSet, +) -> Result<(), WalletStorageError> { + for (subwallet_id, viewing_key) in &changeset.viewing_keys { + if subwallet_id.wallet_id != *wallet_id { + return Err(WalletStorageError::WalletIdMismatch { + expected: *wallet_id, + found: subwallet_id.wallet_id, + }); + } + blob::check_fixed_width( + i64::try_from(viewing_key.len()).unwrap_or(i64::MAX), + VIEWING_KEY_WIDTH, + "shielded_viewing_keys.viewing_key", + )?; + } + + let mut statement = tx.prepare_cached( + "INSERT INTO shielded_viewing_keys (wallet_id, account_index, viewing_key) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(wallet_id, account_index) DO UPDATE SET viewing_key = excluded.viewing_key", + )?; + for (subwallet_id, viewing_key) in &changeset.viewing_keys { + statement.execute(params![ + subwallet_id.wallet_id.as_slice(), + i64::from(subwallet_id.account_index), + viewing_key, + ])?; + } + Ok(()) +} + +/// Read every persisted subwallet viewing key. +/// +/// # Errors +/// +/// Under [`LoadPolicy::Strict`](crate::LoadPolicy) a row that will not +/// decode aborts the read. Under `Recovery` it is counted and skipped, and +/// that subwallet loads without its viewing key. +pub(crate) fn load_all( + conn: &Connection, + ctx: &LoadCtx, +) -> Result>, WalletStorageError> { + let mut statement = conn.prepare_cached( + "SELECT wallet_id, account_index, length(viewing_key), viewing_key \ + FROM shielded_viewing_keys ORDER BY wallet_id, account_index", + )?; + let mut rows = statement.query([])?; + let mut viewing_keys = BTreeMap::new(); + let mut row_number = 0usize; + + while let Some(row) = rows.next()? { + row_number += 1; + let wallet_id_bytes = match row.get::<_, Vec>(0) { + Ok(value) => value, + Err(error) => { + tolerate_skipped_row(ctx, error.into(), row_number, None, None)?; + continue; + } + }; + let account_index = match row.get::<_, i64>(1) { + Ok(value) => value, + Err(error) => { + tolerate_skipped_row(ctx, error.into(), row_number, Some(&wallet_id_bytes), None)?; + continue; + } + }; + let viewing_key_length = match row.get::<_, i64>(2) { + Ok(value) => value, + Err(error) => { + tolerate_skipped_row( + ctx, + error.into(), + row_number, + Some(&wallet_id_bytes), + Some(account_index), + )?; + continue; + } + }; + + let decoded: Result<(SubwalletId, Vec), WalletStorageError> = (|| { + let wallet_id = id32("shielded_viewing_keys.wallet_id", &wallet_id_bytes)?; + let account_index = u32::try_from(account_index).map_err(|_| { + WalletStorageError::blob_decode("shielded_viewing_keys.account_index") + })?; + blob::check_fixed_width( + viewing_key_length, + VIEWING_KEY_WIDTH, + "shielded_viewing_keys.viewing_key", + )?; + let viewing_key = row.get::<_, Vec>(3)?; + blob::check_fixed_width( + i64::try_from(viewing_key.len()).unwrap_or(i64::MAX), + VIEWING_KEY_WIDTH, + "shielded_viewing_keys.viewing_key", + )?; + Ok((SubwalletId::new(wallet_id, account_index), viewing_key)) + })(); + + match decoded { + Ok((subwallet_id, viewing_key)) => { + viewing_keys.insert(subwallet_id, viewing_key); + } + Err(error) => { + tolerate_skipped_row( + ctx, + error, + row_number, + Some(&wallet_id_bytes), + Some(account_index), + )?; + } + } + } + + Ok(viewing_keys) +} 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 index 8c8d05de68e..25df36e4a4d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs @@ -2,15 +2,11 @@ //! //! # Precondition //! -//! Every `identity_id` in the supplied changeset MUST already exist in -//! the `identities` table and belong to the flush's `wallet_id` (or -//! have a NULL `identities.wallet_id` when the scope is the all-zero -//! sentinel). The writer relies on -//! [`super::identities::apply`] for parenting; the FK to -//! `identities(identity_id)` enforces existence but not the wallet -//! match. The precondition check below runs in every build and -//! propagates [`WalletStorageError::WalletIdMismatch`] on a -//! mis-attributed caller. +//! Every `identity_id` MUST already exist in `identities` and belong to the +//! flush's `wallet_id` (or have NULL `wallet_id` for the all-zero sentinel +//! scope). The FK enforces existence; the wallet match is checked here and +//! propagates [`WalletStorageError::WalletIdMismatch`] on a mis-attributed +//! caller. use rusqlite::{params, Transaction}; @@ -20,16 +16,11 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::util::safe_cast; -/// `token_balances` is keyed by `(identity_id, token_id)`. The caller -/// supplies a [`WalletId`] for symmetry with sibling writers and to -/// feed the precondition check; it does not feed any column, because -/// cascade flows -/// `wallet_metadata → identities → token_balances` through the -/// nullable `identities.wallet_id` FK. -// -// Orphan-row policy: there is no automatic prune API. Cascade flows -// through `identities`; hosts that delete identities out-of-band must -// prune `token_balances` themselves. +/// Keyed by `(identity_id, token_id)`. `wallet_id` feeds the precondition +/// check only — no column — since cascade flows +/// `wallets → identities → token_balances` via the nullable +/// `identities.wallet_id` FK. No auto-prune: hosts deleting identities +/// out-of-band must prune `token_balances` themselves. pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/tracked_masternodes.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/tracked_masternodes.rs index edd4c9a4888..d25e076bb34 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/tracked_masternodes.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/tracked_masternodes.rs @@ -14,6 +14,8 @@ use rusqlite::{params, Connection, Transaction}; use platform_wallet::masternode::{snapshot_from_json, snapshot_to_json, TrackedMasternode}; use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; +use crate::sqlite::schema::wallets::network_to_str; use crate::sqlite::util::safe_cast; /// Replace every row for `network` with `records`. @@ -22,7 +24,11 @@ pub fn replace_all( network: dashcore::Network, records: &[TrackedMasternode], ) -> Result<(), WalletStorageError> { - let network = network.to_string(); + // Bound through `network_to_str`, not `Display`: the V006 CHECK pins + // this label domain and `network_labels_match_enum` is what keeps the + // two in step. A `Display` change upstream would otherwise turn every + // write into a CHECK failure with no compile-time signal. + let network = network_to_str(network); tx.execute( "DELETE FROM tracked_masternodes WHERE network = ?1", params![network], @@ -49,16 +55,24 @@ pub fn replace_all( } /// Every row for `network`, oldest-tracked first. +/// +/// # Errors +/// +/// [`WalletStorageError::InvalidWalletIdLength`] for a `pro_tx_hash` that +/// is not 32 bytes, under [`LoadPolicy::Strict`](crate::LoadPolicy). Under +/// Recovery the row is skipped and counted into the degradation report +/// rather than vanishing silently. pub fn load_all( conn: &Connection, network: dashcore::Network, + ctx: &LoadCtx, ) -> Result, WalletStorageError> { let mut stmt = conn.prepare_cached( "SELECT pro_tx_hash, label, added_at, snapshot_json \ FROM tracked_masternodes WHERE network = ?1 \ ORDER BY added_at, pro_tx_hash", )?; - let rows = stmt.query_map(params![network.to_string()], |row| { + let rows = stmt.query_map(params![network_to_str(network)], |row| { let hash: Vec = row.get(0)?; let label: Option = row.get(1)?; let added_at: i64 = row.get(2)?; @@ -68,10 +82,18 @@ pub fn load_all( let mut out = Vec::new(); for row in rows { let (hash, label, added_at, snapshot) = row?; - let Ok(pro_tx_hash) = <[u8; 32]>::try_from(hash.as_slice()) else { - // Length is CHECK-constrained; a mismatch means external - // tampering — skip rather than fail the whole load. - continue; + let pro_tx_hash = match <[u8; 32]>::try_from(hash.as_slice()) { + Ok(pro_tx_hash) => pro_tx_hash, + Err(_) => { + ctx.tolerate( + LoadSite::TrackedMasternodeIdLength, + WalletStorageError::InvalidWalletIdLength { + column: "tracked_masternodes.pro_tx_hash", + actual: hash.len(), + }, + )?; + continue; + } }; out.push(TrackedMasternode { pro_tx_hash, @@ -82,3 +104,93 @@ pub fn load_all( } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A row whose `pro_tx_hash` is not 32 bytes — the shape that can only + /// reach the file with the V006 CHECK bypassed. + fn conn_with_short_pro_tx_hash() -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn.execute_batch("PRAGMA ignore_check_constraints = ON;") + .unwrap(); + conn.execute( + "INSERT INTO tracked_masternodes \ + (network, pro_tx_hash, label, added_at, snapshot_json) \ + VALUES ('testnet', ?1, 'short', 1, '{}')", + params![&[0xAAu8; 8][..]], + ) + .unwrap(); + conn + } + + /// Strict's contract is that any inconsistency aborts the load. A + /// silent `continue` returned `Ok` with the row simply gone. + #[test] + fn strict_aborts_on_a_short_pro_tx_hash() { + let conn = conn_with_short_pro_tx_hash(); + let err = load_all(&conn, dashcore::Network::Testnet, &LoadCtx::strict()) + .expect_err("Strict must abort, not return Ok with the row dropped"); + match err { + WalletStorageError::InvalidWalletIdLength { column, actual } => { + assert_eq!(column, "tracked_masternodes.pro_tx_hash"); + assert_eq!(actual, 8); + } + other => panic!("expected InvalidWalletIdLength, got {other:?}"), + } + } + + /// Recovery may drop the row, but the degradation report is the whole + /// point of the mode — a drop that reports clean is the worst outcome. + #[test] + fn recovery_counts_the_dropped_row_into_the_degradation_report() { + let conn = conn_with_short_pro_tx_hash(); + let ctx = LoadCtx::recovery(); + let rows = load_all(&conn, dashcore::Network::Testnet, &ctx) + .expect("Recovery must tolerate the row rather than fail"); + assert!(rows.is_empty(), "the malformed row cannot be rehydrated"); + let degradation = ctx.degradation(); + assert!( + degradation.degraded, + "a dropped row must mark the load degraded" + ); + assert_eq!( + degradation + .by_site + .get(&LoadSite::TrackedMasternodeIdLength), + Some(&1), + "the drop must be counted at its own site" + ); + } + + /// `network` is CHECK-constrained to the label domain `network_to_str` + /// produces. Binding it through `Display` instead would turn an + /// upstream rendering change into a silent CHECK failure on every + /// write, with no compile-time signal. + #[test] + fn every_network_label_round_trips_through_the_check_constraint() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + for network in [ + dashcore::Network::Mainnet, + dashcore::Network::Testnet, + dashcore::Network::Devnet, + dashcore::Network::Regtest, + ] { + let record = TrackedMasternode { + pro_tx_hash: [0x31u8; 32], + label: None, + added_at: 0, + snapshot: Default::default(), + }; + let tx = conn.transaction().unwrap(); + replace_all(&tx, network, std::slice::from_ref(&record)) + .expect("the bound label must satisfy the network CHECK"); + tx.commit().unwrap(); + let rows = load_all(&conn, network, &LoadCtx::strict()).expect("load"); + assert_eq!(rows.len(), 1, "{network:?} must round-trip"); + } + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs new file mode 100644 index 00000000000..bfbb9af6486 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs @@ -0,0 +1,325 @@ +//! Store-scoped version + generation metadata: `meta_data_versions` and +//! `meta_store_generation`. +//! +//! `meta_data_versions` carries a monotonic `seq` per `(wallet_id, domain)`, +//! bumped inside the flush transaction so a domain's cache-invalidation +//! marker and its data commit atomically. `meta_store_generation` holds the +//! single store-generation token, stable across flushes and regenerated on +//! restore. +//! +//! `read_seq` and `read_generation` are intentionally test-only until the +//! first production cache-invalidation consumer needs these accessors. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::{Merge, PlatformWalletChangeSet}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; + +/// A wallet-state family whose durable version `seq` is bumped when the +/// matching changeset field is flushed. One variant per persisted +/// changeset field — the cache-invalidation keystone (R8). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Domain { + Core, + Identities, + IdentityKeys, + Contacts, + PlatformAddresses, + AssetLocks, + TokenBalances, + DashpayProfiles, + DashpayPaymentsOverlay, + Wallets, + AccountRegistrations, + CoreAddressPool, + PendingContactCrypto, + Invitations, + DpnsNameStates, + IdentityScanState, + #[cfg(feature = "shielded")] + ShieldedViewingKeys, +} + +impl Domain { + /// Stable `meta_data_versions.domain` label: the live SQL table name persisted in every database. + /// Renaming a Rust variant must never change this string without a migration. + pub fn as_str(self) -> &'static str { + match self { + Domain::Core => "core", + Domain::Identities => "identities", + Domain::IdentityKeys => "identity_keys", + Domain::Contacts => "contacts", + Domain::PlatformAddresses => "platform_addresses", + Domain::AssetLocks => "asset_locks", + Domain::TokenBalances => "token_balances", + Domain::DashpayProfiles => "dashpay_profiles", + Domain::DashpayPaymentsOverlay => "dashpay_payments_overlay", + Domain::Wallets => "wallets", + Domain::AccountRegistrations => "account_registrations", + Domain::CoreAddressPool => "core_address_pool", + Domain::PendingContactCrypto => "pending_contact_crypto", + Domain::Invitations => "invitations", + Domain::DpnsNameStates => "dpns_name_states", + Domain::IdentityScanState => "identity_scan_states", + #[cfg(feature = "shielded")] + Domain::ShieldedViewingKeys => "shielded_viewing_keys", + } + } + + /// Every domain, for coverage tests. + #[cfg(any(test, feature = "__test-helpers"))] + #[cfg(feature = "shielded")] + pub const ALL: [Domain; 17] = [ + Domain::Core, + Domain::Identities, + Domain::IdentityKeys, + Domain::Contacts, + Domain::PlatformAddresses, + Domain::AssetLocks, + Domain::TokenBalances, + Domain::DashpayProfiles, + Domain::DashpayPaymentsOverlay, + Domain::Wallets, + Domain::AccountRegistrations, + Domain::CoreAddressPool, + Domain::PendingContactCrypto, + Domain::Invitations, + Domain::DpnsNameStates, + Domain::IdentityScanState, + Domain::ShieldedViewingKeys, + ]; + + /// Every domain, for coverage tests without shielded persistence. + #[cfg(all(any(test, feature = "__test-helpers"), not(feature = "shielded")))] + pub const ALL: [Domain; 16] = [ + Domain::Core, + Domain::Identities, + Domain::IdentityKeys, + Domain::Contacts, + Domain::PlatformAddresses, + Domain::AssetLocks, + Domain::TokenBalances, + Domain::DashpayProfiles, + Domain::DashpayPaymentsOverlay, + Domain::Wallets, + Domain::AccountRegistrations, + Domain::CoreAddressPool, + Domain::PendingContactCrypto, + Domain::Invitations, + Domain::DpnsNameStates, + Domain::IdentityScanState, + ]; +} + +/// Domains carrying data in `cs`. The destructure is exhaustive (no `..`), so +/// adding a field to `PlatformWalletChangeSet` is a compile error here until +/// it gains a `Domain` variant and an arm below — the R8 forgotten-domain +/// guard. The `shielded` field is present in every feature combination and is +/// named in both arms of the pattern; the `cfg` chooses whether this crate +/// *reads* it, never whether it exists. It maps only the natively persisted +/// viewing keys; notes and sync state remain in the host `ShieldedStore`. +/// +/// `account_registrations` and `provider_key_account_registrations` share +/// [`Domain::AccountRegistrations`]: both land in `account_registrations` +/// rows, so one seq covers the whole account manifest. +pub fn touched_domains(cs: &PlatformWalletChangeSet) -> Vec { + let PlatformWalletChangeSet { + core, + identities, + identity_keys, + contacts, + platform_addresses, + asset_locks, + token_balances, + dashpay_profiles, + dashpay_payments_overlay, + wallet_metadata, + account_registrations, + provider_key_account_registrations, + account_address_pools, + pending_contact_crypto_added, + pending_contact_crypto_cleared, + invitations, + dpns_name_states, + identity_scan_state, + #[cfg(feature = "shielded")] + shielded, + #[cfg(not(feature = "shielded"))] + shielded: _, + } = cs; + + // A sub-changeset carried but empty (`Some(default)`) is not a real + // change; the `Merge::is_empty` bound is the shared emptiness contract. + fn present(opt: &Option) -> bool { + !opt.is_empty() + } + + let mut out = Vec::new(); + if present(core) { + out.push(Domain::Core); + } + if present(identities) { + out.push(Domain::Identities); + } + if present(identity_keys) { + out.push(Domain::IdentityKeys); + } + if present(contacts) { + out.push(Domain::Contacts); + } + if present(platform_addresses) { + out.push(Domain::PlatformAddresses); + } + if present(asset_locks) { + out.push(Domain::AssetLocks); + } + if present(token_balances) { + out.push(Domain::TokenBalances); + } + if dashpay_profiles.as_ref().is_some_and(|m| !m.is_empty()) { + out.push(Domain::DashpayProfiles); + } + if dashpay_payments_overlay + .as_ref() + .is_some_and(|m| !m.is_empty()) + { + out.push(Domain::DashpayPaymentsOverlay); + } + if wallet_metadata.is_some() { + out.push(Domain::Wallets); + } + if !account_registrations.is_empty() || !provider_key_account_registrations.is_empty() { + out.push(Domain::AccountRegistrations); + } + if !account_address_pools.is_empty() { + out.push(Domain::CoreAddressPool); + } + if !pending_contact_crypto_added.is_empty() || !pending_contact_crypto_cleared.is_empty() { + out.push(Domain::PendingContactCrypto); + } + if present(invitations) { + out.push(Domain::Invitations); + } + if present(dpns_name_states) { + out.push(Domain::DpnsNameStates); + } + // Not a `Merge` type — a verdict is one absolute value, not a mergeable + // collection, so presence is the whole test. + if identity_scan_state.is_some() { + out.push(Domain::IdentityScanState); + } + #[cfg(feature = "shielded")] + if shielded + .as_ref() + .is_some_and(|changeset| !changeset.viewing_keys.is_empty()) + { + out.push(Domain::ShieldedViewingKeys); + } + out +} + +/// Saturating increment of one domain's `seq`, inside the caller's flush tx. +/// The first bump sets `seq = 1`; thereafter it increments but never wraps past +/// `i64::MAX` — a wrap to a lower value would look like a rollback to a +/// client's memoized `(generation, domain, seq)` cache and silently +/// reintroduce staleness (the exact bug class R8 exists to prevent). +pub fn bump_domain( + tx: &Transaction<'_>, + wallet_id: &WalletId, + domain: Domain, +) -> Result<(), WalletStorageError> { + tx.prepare_cached( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) VALUES (?1, ?2, 1) \ + ON CONFLICT(wallet_id, domain) DO UPDATE SET \ + seq = CASE WHEN seq >= 9223372036854775807 THEN seq ELSE seq + 1 END", + )? + .execute(params![wallet_id.as_slice(), domain.as_str()])?; + Ok(()) +} + +/// Bump every domain touched by `cs`, inside the caller's flush tx. +pub fn bump_touched_domains( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &PlatformWalletChangeSet, +) -> Result<(), WalletStorageError> { + for domain in touched_domains(cs) { + bump_domain(tx, wallet_id, domain)?; + } + Ok(()) +} + +/// Read the current `seq` for one `(wallet_id, domain)`; `0` when the domain +/// has never been bumped (no row). +#[cfg(any(test, feature = "__test-helpers"))] +pub fn read_seq( + conn: &rusqlite::Connection, + wallet_id: &WalletId, + domain: Domain, +) -> Result { + use rusqlite::OptionalExtension; + let seq: Option = conn + .query_row( + "SELECT seq FROM meta_data_versions WHERE wallet_id = ?1 AND domain = ?2", + params![wallet_id.as_slice(), domain.as_str()], + |row| row.get(0), + ) + .optional()?; + Ok(seq.unwrap_or(0)) +} + +/// Read the 16-byte store-generation token written by V009. `None` on a +/// pre-V009 store (the table is absent). +#[cfg(any(test, feature = "__test-helpers"))] +pub fn read_generation( + conn: &rusqlite::Connection, +) -> Result, WalletStorageError> { + use rusqlite::OptionalExtension; + if !generation_table_exists(conn)? { + return Ok(None); + } + let bytes: Option> = conn + .query_row( + "SELECT generation FROM meta_store_generation WHERE id = 0", + [], + |row| row.get(0), + ) + .optional()?; + match bytes { + None => Ok(None), + Some(b) => { + let arr: [u8; 16] = b.as_slice().try_into().map_err(|_| { + WalletStorageError::blob_decode("meta_store_generation.generation not 16 bytes") + })?; + Ok(Some(arr)) + } + } +} + +/// Regenerate the store-generation token so a restored copy is +/// distinguishable from its source. A no-op on a pre-V009 store (no table); +/// such a store gets a fresh token when it later migrates to V009. +pub fn regenerate_generation(conn: &rusqlite::Connection) -> Result<(), WalletStorageError> { + if !generation_table_exists(conn)? { + return Ok(()); + } + conn.execute( + "UPDATE meta_store_generation SET generation = randomblob(16) WHERE id = 0", + [], + )?; + Ok(()) +} + +fn generation_table_exists(conn: &rusqlite::Connection) -> Result { + use rusqlite::OptionalExtension; + Ok(conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta_store_generation'", + [], + |_| Ok(()), + ) + .optional()? + .is_some()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/wallets.rs similarity index 61% rename from packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs rename to packages/rs-platform-wallet-storage/src/sqlite/schema/wallets.rs index a76b517ca73..0ff9d55a826 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/wallets.rs @@ -1,4 +1,4 @@ -//! `wallet_metadata` writer + helpers. +//! `wallets` writer + helpers. use rusqlite::{params, Connection, Transaction}; @@ -7,7 +7,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; -/// Insert / replace a `wallet_metadata` row. +/// Insert / replace a `wallets` row. pub fn upsert( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -15,7 +15,7 @@ pub fn upsert( ) -> Result<(), WalletStorageError> { let network = network_to_str(entry.network); let mut stmt = tx.prepare_cached( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, ?2, ?3) \ ON CONFLICT(wallet_id) DO UPDATE SET network = excluded.network, \ birth_height = excluded.birth_height", @@ -24,16 +24,12 @@ pub fn upsert( 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). +/// Ensure a `wallets` parent row exists for the given id (test helper). +/// Idempotent; defaults `network = "testnet"`, `birth_height = 0`. #[cfg(any(test, feature = "__test-helpers"))] pub fn ensure_exists(conn: &Connection, wallet_id: &WalletId) -> Result<(), WalletStorageError> { conn.execute( - "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, ?2, ?3)", params![wallet_id.as_slice(), "testnet", 0i64], )?; @@ -42,67 +38,54 @@ pub fn ensure_exists(conn: &Connection, wallet_id: &WalletId) -> Result<(), Wall /// 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 mut stmt = conn.prepare("SELECT wallet_id FROM wallets 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(), - } - })?; + let wid = super::id32("wallets.wallet_id", &bytes)?; out.push(wid); } Ok(out) } /// Lookup `(network, birth_height)` for a wallet, if known. -#[cfg(any(test, feature = "__test-helpers"))] 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")?; + conn.prepare("SELECT network, birth_height FROM wallets 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, - })?; + let height = crate::sqlite::util::safe_cast::i64_to_u32("wallets.birth_height", height)?; Ok(Some((network, height))) } else { Ok(None) } } -/// Delete a wallet_metadata row (native `ON DELETE CASCADE` fires). +/// Delete a wallets 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", + "DELETE FROM wallets WHERE wallet_id = ?1", params![wallet_id.as_slice()], )?; Ok(n) } -/// Single source of truth for the `wallet_metadata.network` TEXT-column -/// domain. -/// -/// Mirrors every variant of [`key_wallet::Network`] (writer side: -/// [`network_to_str`]). The migration in `migrations/V001__initial.rs` -/// interpolates this array into a `CHECK (network IN (...))` clause so -/// an unknown label is rejected at insert time rather than landing as -/// silent garbage. The `network_labels_match_enum` unit test below -/// enforces set-equality between this array and the writer's output — -/// drift (a renamed/added variant) becomes a failing test, not a -/// runtime divergence between Rust and SQLite. +/// Source of truth for the `wallets.network` TEXT domain, mirroring +/// [`key_wallet::Network`]. The migrations interpolate nothing: V001 freezes +/// its own copy of this domain, because a generated-SQL change breaks that +/// migration's Refinery checksum on every database that already applied it. +/// `network_labels_match_enum` pins this array to [`network_to_str`]; +/// `network_labels_frozen_in_v001` pins it to V001's frozen list. +#[cfg(test)] pub(crate) const NETWORK_LABELS: &[&str] = &["mainnet", "testnet", "devnet", "regtest"]; -fn network_to_str(net: key_wallet::Network) -> &'static str { +pub(crate) fn network_to_str(net: key_wallet::Network) -> &'static str { match net { key_wallet::Network::Mainnet => "mainnet", key_wallet::Network::Testnet => "testnet", @@ -112,7 +95,6 @@ fn network_to_str(net: key_wallet::Network) -> &'static str { } /// Inverse of `network_to_str`. -#[cfg(any(test, feature = "__test-helpers"))] pub fn parse_network(s: &str) -> Option { match s { "mainnet" => Some(key_wallet::Network::Mainnet), @@ -128,13 +110,9 @@ mod tests { use super::*; use std::collections::HashSet; - /// Every [`key_wallet::Network`] variant — kept exhaustive by the - /// `match` arm below, which the compiler's exhaustiveness check - /// turns into a build failure if upstream adds a variant. + /// Every [`key_wallet::Network`] variant; the `match` below fails to + /// compile if upstream adds one, keeping the list in lockstep. fn all_network_variants() -> Vec { - // The match's exhaustiveness fails to compile on a new variant. - // Mapping every existing variant to itself keeps the list and the - // enum in lockstep. let variants = [ key_wallet::Network::Mainnet, key_wallet::Network::Testnet, @@ -167,6 +145,18 @@ mod tests { ); } + /// Pins the live domain to the list frozen in `V001__initial.rs`. + /// + /// IF THIS FAILS: do NOT edit V001's list to match. Refinery checksums a + /// migration's rendered SQL, so changing an applied migration's body makes + /// every database that already ran it fail to open, permanently. Append a + /// migration rebuilding the table with the widened CHECK (the + /// `V004__asset_lock_recovered_status.rs` pattern), then update this pin. + #[test] + fn network_labels_frozen_in_v001() { + assert_eq!(NETWORK_LABELS, &["mainnet", "testnet", "devnet", "regtest"]); + } + #[test] fn parse_network_round_trips_every_label() { for label in NETWORK_LABELS { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs index 921ef15f9a4..3d435635a54 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs @@ -1,4 +1,12 @@ //! Shared internal helpers (safe casts, file permissions, etc.). +// `permissions` backs the persister's and backup writer's on-disk hardening and +// has no production caller outside this crate, so it stays crate-private rather +// than becoming semver surface. `__test-helpers` widens it the same way `schema` +// and `migrations` are widened in `sqlite/mod.rs`, so this crate's own +// integration tests can assert the applied modes directly. +#[cfg(any(test, feature = "__test-helpers"))] pub mod permissions; +#[cfg(not(any(test, feature = "__test-helpers")))] +pub(crate) 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 index 6e23638d3c6..9bc80154e6f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs @@ -9,15 +9,45 @@ use std::path::Path; use crate::sqlite::error::WalletStorageError; +/// Refuse `path` when it is a symbolic link. +/// +/// `O_CREAT|O_EXCL` returns `EEXIST` for a symlink whether or not its +/// target exists, so `EEXIST` alone cannot tell a legitimate existing +/// database from a planted link — and every operation that follows (the +/// SQLite open, the `0o600` chmod) resolves the link. A missing path is +/// accepted: the caller creates it with `O_EXCL`, which cannot be +/// redirected. +/// +/// # Errors +/// +/// [`WalletStorageError::DatabasePathIsSymlink`] when `path` is a link; +/// [`WalletStorageError::Io`] when its metadata cannot be read. +pub fn reject_symlink(path: &Path) -> Result<(), WalletStorageError> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + Err(WalletStorageError::DatabasePathIsSymlink { + path: path.to_path_buf(), + }) + } + Ok(_) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(WalletStorageError::Io(e)), + } +} + /// Pre-create the DB file at `path` with mode `0o600` before rusqlite /// opens it, closing the umask window (the file is born owner-only, not /// created at the umask default then chmod'd) and the final-component -/// symlink redirect (`create_new` uses `O_EXCL`, so an attacker-planted -/// symlink at `path` makes the create fail rather than redirect). +/// symlink redirect. /// /// A no-op when the file already exists (re-open of an existing DB) — the /// live mode is then re-tightened by [`apply_secure_permissions`] after /// open. No-op on non-Unix. +/// +/// # Errors +/// +/// [`WalletStorageError::DatabasePathIsSymlink`] when `path` is a symlink +/// rather than the database itself. #[allow(unused_variables)] pub fn precreate_secure(path: &Path) -> Result<(), WalletStorageError> { #[cfg(unix)] @@ -31,8 +61,10 @@ pub fn precreate_secure(path: &Path) -> Result<(), WalletStorageError> { { Ok(_file) => {} // Already present — re-open path; the real open + the - // post-open chmod handle it. - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + // post-open chmod handle it. `O_EXCL` reports EEXIST for a + // planted symlink too, so the redirect is ruled out here + // rather than followed by the open and the chmod below. + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => reject_symlink(path)?, Err(e) => return Err(WalletStorageError::Io(e)), } } 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 index c02632913b2..1ef1f5823ac 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs @@ -1,18 +1,10 @@ //! 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 +//! SQLite's `INTEGER` affinity is `i64`, but wallet types are `u64`; a +//! naive `as i64` wraps values ≥ `i64::MAX` to negatives and sign-extends +//! them back on read. Every durable boundary cast routes through one of +//! these helpers, which return 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; @@ -23,6 +15,8 @@ pub enum SafeCastTarget { I64, #[error("u64")] U64, + #[error("u32")] + U32, } /// Cast `value: u64` to `i64`, surfacing @@ -40,24 +34,62 @@ pub fn u64_to_i64(field: &'static str, value: u64) -> Result 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. + // Surface the original bit pattern, not post-cast garbage. value: value as u64, target: SafeCastTarget::U64, }) } +/// Cast a stored `i64` column to `u32`, surfacing +/// [`WalletStorageError::IntegerOverflow`] when the value is negative or +/// exceeds `u32::MAX`. The single boundary helper for the readers that +/// map `INTEGER` columns (heights, account/address indices, nonces) back +/// to their `u32` Rust types. +/// +/// `field` is a compile-time identifier (e.g. +/// `"core_sync_state.synced_height"`) naming the column so the resulting +/// error is actionable. +pub fn i64_to_u32(field: &'static str, value: i64) -> Result { + u32::try_from(value).map_err(|_| WalletStorageError::IntegerOverflow { + field, + value: value as u64, + target: SafeCastTarget::U32, + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn i64_to_u32_happy_path() { + assert_eq!(i64_to_u32("x", 0).unwrap(), 0); + assert_eq!(i64_to_u32("x", u32::MAX as i64).unwrap(), u32::MAX); + } + + #[test] + fn i64_to_u32_overflow_high_and_negative() { + assert!(matches!( + i64_to_u32("h", i64::from(u32::MAX) + 1).unwrap_err(), + WalletStorageError::IntegerOverflow { + target: SafeCastTarget::U32, + .. + } + )); + assert!(matches!( + i64_to_u32("h", -1).unwrap_err(), + WalletStorageError::IntegerOverflow { + target: SafeCastTarget::U32, + .. + } + )); + } + #[test] fn u64_to_i64_happy_path() { assert_eq!(u64_to_i64("x", 0).unwrap(), 0); diff --git a/packages/rs-platform-wallet-storage/tests/common/mod.rs b/packages/rs-platform-wallet-storage/tests/common/mod.rs index cc0f4d1d70d..d9d62a4f6d5 100644 --- a/packages/rs-platform-wallet-storage/tests/common/mod.rs +++ b/packages/rs-platform-wallet-storage/tests/common/mod.rs @@ -10,7 +10,7 @@ use platform_wallet::changeset::PlatformWalletPersistence; use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::Connection; -pub use platform_wallet_storage::{FlushMode, SqlitePersister, SqlitePersisterConfig}; +pub use platform_wallet_storage::{FlushMode, LoadPolicy, SqlitePersister, SqlitePersisterConfig}; /// Open an empty temp directory + persister for one test. Returns the /// persister, the keep-alive `tempfile::TempDir`, and the DB path. @@ -19,13 +19,42 @@ pub fn fresh_persister() -> (SqlitePersister, tempfile::TempDir, PathBuf) { } pub fn fresh_persister_with_mode(mode: FlushMode) -> (SqlitePersister, tempfile::TempDir, PathBuf) { - let tmp = tempfile::tempdir().expect("tempdir"); + let tmp = secure_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) } +/// Seed a database through a strict persister, then reopen it in +/// [`LoadPolicy::Recovery`]. +/// +/// The strict handle is dropped before the reopen: the process-wide +/// open-path registry refuses a second live persister on one path. +pub fn fresh_recovery_persister( + seed: impl FnOnce(&SqlitePersister), +) -> (SqlitePersister, tempfile::TempDir, PathBuf) { + let tmp = secure_tempdir().expect("tempdir"); + let path = tmp.path().join("wallet.db"); + let strict = SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("open strict"); + seed(&strict); + drop(strict); + let cfg = SqlitePersisterConfig::new(&path).with_load_policy(LoadPolicy::Recovery); + let p = SqlitePersister::open(cfg).expect("open recovery"); + (p, tmp, path) +} + +/// Create a test directory that satisfies the persister's Unix parent policy. +pub fn secure_tempdir() -> std::io::Result { + let tmp = tempfile::tempdir()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700))?; + } + Ok(tmp) +} + /// Wallet id helper. pub fn wid(byte: u8) -> WalletId { [byte; 32] @@ -41,18 +70,18 @@ pub fn ro_conn(path: &std::path::Path) -> Connection { .expect("open ro conn") } -/// Insert a stub `wallet_metadata` row so child writes pass the native +/// Insert a stub `wallets` 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) \ + "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", params![wallet_id.as_slice()], ) - .expect("ensure wallet_metadata"); + .expect("ensure wallets"); } /// Insert a stub `identities` row so identity-owned table writes @@ -66,16 +95,14 @@ pub fn ensure_identity( 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"); + // Delegate to the production stub writer so `entry_blob` holds a + // real, decodable `IdentityEntry` (the wired `load()` decodes every + // identity row). The all-zero sentinel WalletId maps to a NULL + // `wallet_id` column, so `None` lands as an orphan identity. + let scope: WalletId = parent_wallet_id.copied().unwrap_or([0u8; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists(&conn, &scope, identity_id) + .expect("ensure identity"); } /// Insert a stub `token_balances` row so `meta_token` writes pass the @@ -100,7 +127,7 @@ pub fn ensure_token_balance( /// Insert a stub `established` row in the unified `contacts` table so /// the `cascade_meta_contact_on_contact_delete` trigger has an /// established-contact parent to fire on for `meta_contact` writes keyed -/// by `(wallet_id, owner_id, contact_id)`. The parent `wallet_metadata` +/// by `(wallet_id, owner_id, contact_id)`. The parent `wallets` /// row must already exist (seed via [`ensure_wallet_meta`]). pub fn ensure_contact_established( persister: &SqlitePersister, @@ -121,7 +148,7 @@ pub fn ensure_contact_established( /// Insert a stub `sent` contact row (pending outgoing request) so a /// `meta_contact` write keyed by `(wallet_id, owner_id, contact_id)` has -/// a non-established parent to exercise. The parent `wallet_metadata` +/// a non-established parent to exercise. The parent `wallets` /// row must already exist. pub fn ensure_contact_sent( persister: &SqlitePersister, @@ -162,7 +189,7 @@ pub fn ensure_contact_received( /// Insert a stub `platform_addresses` row so `meta_platform_address` /// writes pass the composite FK to /// `platform_addresses(wallet_id, address)`. The parent -/// `wallet_metadata` row must already exist (seed via +/// `wallets` row must already exist (seed via /// [`ensure_wallet_meta`]). `address` is an opaque BLOB. pub fn ensure_platform_address(persister: &SqlitePersister, wallet_id: &WalletId, address: &[u8]) { use rusqlite::params; @@ -176,6 +203,51 @@ pub fn ensure_platform_address(persister: &SqlitePersister, wallet_id: &WalletId .expect("ensure platform_address"); } +/// Run `action` on another thread, released exactly at the seam +/// between a `store()`'s buffer merge and its flush, and block that +/// `store()` for up to `budget` waiting for `action` to return. +/// +/// This is how the crate tests what a second actor can and cannot do +/// inside that window without racing for it. An `Immediate` `store()` +/// holds the write connection across the whole seam, so an `action` +/// that needs it is parked until the `store()` returns and the wait +/// simply expires; waiting is not the assertion, it only guarantees the +/// action had its chance, so no outcome rides on thread scheduling. +/// +/// The seam is ONE-SHOT: `action` may itself call `store`, and that +/// call must not be released back into this same rendezvous. +pub fn release_at_store_seam( + persister: &std::sync::Arc, + budget: std::time::Duration, + action: F, +) -> std::thread::JoinHandle +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, +{ + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{mpsc, Arc, Mutex}; + + let (go_tx, go_rx) = mpsc::channel::<()>(); + let (done_tx, done_rx) = mpsc::channel::<()>(); + let handle = std::thread::spawn(move || { + go_rx.recv().expect("the seam released the action"); + let out = action(); + let _ = done_tx.send(()); + out + }); + let done_rx = Mutex::new(done_rx); + let released = AtomicBool::new(false); + persister.set_store_flush_seam_for_test(Arc::new(move || { + if released.swap(true, Ordering::SeqCst) { + return; + } + go_tx.send(()).expect("the action thread is listening"); + let _ = done_rx.lock().expect("seam channel").recv_timeout(budget); + })); + handle +} + /// Echo a simple `store` + `flush` of an arbitrary changeset. pub fn store_and_flush( persister: &SqlitePersister, diff --git a/packages/rs-platform-wallet-storage/tests/fixture_gen.rs b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs new file mode 100644 index 00000000000..4884b364c0d --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs @@ -0,0 +1,414 @@ +//! Provenance and guard for the `v4.2-dev` database fixture. +//! +//! `tests/fixtures/v4_2_dev_migrated.db` is deliberately NOT produced by this +//! crate's migration runner. The whole point of the fixture is that a database +//! this branch did not create still opens, so generating it here would assume +//! the thing under test. It is created by `v4.2-dev`'s OWN shipped +//! default-feature binary and only then seeded, by the `#[ignore]` test below. +//! +//! Hand-writing a `refinery_schema_history` table would be worse than useless: +//! the checksum is SipHasher13 over `(name, version, rendered SQL)`, so a +//! forgery can pass where a real database fails -- which is the exact failure +//! this fixture exists to catch. +//! +//! Regenerating it: +//! ```text +//! git worktree add --detach /data/git-worktrees/-base origin/v4.2-dev +//! cargo build -p platform-wallet-storage --bin platform-wallet-storage --features cli +//! /debug/platform-wallet-storage --db /v42.db migrate --no-auto-backup +//! V4_2_DEV_DB=/v42.db cargo test -p platform-wallet-storage --test fixture_gen -- --ignored +//! ``` +//! The parent directory chain of `/v42.db` must not be group- or +//! world-writable, or the persister refuses it as an insecure parent. + +mod common; + +use std::path::{Path, PathBuf}; + +use common::wid; +use dpp::prelude::Identifier; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use platform_wallet::changeset::{AccountRegistrationEntry, IdentityEntry}; +use platform_wallet::wallet::identity::{ContactRequest, IdentityStatus}; +use platform_wallet_storage::sqlite::schema::blob; + +/// The two wallets the fixture carries: one populated, one bare. +const FIXTURE_WALLET: u8 = 0xA1; +const EMPTY_WALLET: u8 = 0xB2; +/// The identity the fixture carries, owned by that wallet. +const FIXTURE_IDENTITY: [u8; 32] = [0xC1; 32]; + +/// Historical base fixture version. It predates the subsequently published +/// V007; both that migration and these V001-V006 bodies remain immutable. +const V4_2_DEV_SCHEMA_VERSION: i64 = 6; + +/// `PRAGMA application_id` of a database created before `V008` stamped it: +/// SQLite's default for a file nobody stamped. +const UNSTAMPED_APPLICATION_ID: i64 = 0; + +fn fixture_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("v4_2_dev_migrated.db") +} + +/// First external address of the Standard BIP44 account 0, derived from fixed +/// bytes so the UTXO lands on a real, script-round-trippable address. +fn first_external_info(byte: u8) -> key_wallet::AddressInfo { + use key_wallet::managed_account::address_pool::AddressPoolType; + let wallet = Wallet::from_seed_bytes( + [byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_account_type(), + AccountType::Standard { index: 0, .. } + ) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let mut infos: Vec<_> = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos.first().cloned().unwrap(); + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +/// A chain-locked transaction record at height 200, so the migrated store can +/// be asserted to preserve both the height column and the blob's own context. +fn one_tx_record() -> key_wallet::managed_account::transaction_record::TransactionRecord { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, Txid}; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + let mut record = TransactionRecord::new( + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 200, + BlockHash::from_byte_array([0x03; 32]), + 1_735_689_600, + )), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 150_000, + ); + record.txid = Txid::from_byte_array([0x7E; 32]); + record +} + +fn identity_entry() -> IdentityEntry { + IdentityEntry { + id: Identifier::from(FIXTURE_IDENTITY), + balance: 42, + revision: 1, + identity_index: Some(0), + 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: Some(wid(FIXTURE_WALLET)), + dashpay_profile: None, + dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } +} + +/// Insert rows shaped the way a `v4.2-dev` writer would have left them. +/// +/// Raw SQL against the V001-V006 schema: the branch's writers target tables +/// that do not exist yet at this point in history (`wallets`, +/// `core_address_pool`), so they cannot be used. The blob encoders CAN -- +/// `key-wallet` is pinned to the same revision on both sides and +/// `AccountRegistrationEntry` is unchanged, so these bytes are exactly what a +/// `v4.2-dev` build would have written. +fn seed_base_shaped_rows(conn: &rusqlite::Connection) { + use rusqlite::params; + + let wallet = wid(FIXTURE_WALLET); + // The legacy label is retained; its blob identifies the exact variant. + let key_wallet = Wallet::from_seed_bytes( + [FIXTURE_WALLET; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let registration = AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + account_xpub: key_wallet + .accounts + .account_of_type(AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }) + .unwrap() + .account_xpub, + }; + let registration_blob = blob::encode(®istration).expect("encode registration"); + let identity_blob = blob::encode(&identity_entry()).expect("encode identity"); + + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![wallet.as_slice()], + ) + .expect("insert wallet_metadata"); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'standard', 0, ?2)", + params![wallet.as_slice(), registration_blob], + ) + .expect("insert account_registrations"); + // Real public pool state from the published writer's bincode-serde codec. + let mut info = first_external_info(FIXTURE_WALLET); + info.state = key_wallet::managed_account::address_pool::AddressState::Used; + let snapshot = platform_wallet::changeset::AccountAddressPoolEntry { + account_type: registration.account_type, + pool_type: key_wallet::managed_account::address_pool::AddressPoolType::External, + addresses: vec![info.clone()], + }; + let snapshot = bincode::serde::encode_to_vec(snapshot, bincode::config::standard()).unwrap(); + conn.execute( + "INSERT INTO account_address_pools \ + (wallet_id, account_type, account_index, pool_type, snapshot_blob) \ + VALUES (?1, 'standard', 0, 'external', ?2)", + params![wallet.as_slice(), snapshot], + ) + .expect("insert account_address_pools"); + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, address, derivation_path, used) \ + VALUES (?1, 'standard', 0, ?2, 'external/0', 1)", + params![wallet.as_slice(), info.address.to_string()], + ) + .expect("insert core_derived_addresses"); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, 100, 100)", + params![wallet.as_slice()], + ) + .expect("insert core_sync_state"); + // No `identity_keys` row here: a valid `public_key_blob` is an encoded + // `IdentityKeyWire`, and a placeholder would fail the load path rather + // than the migration. V008's `identity_keys` rebuild and its wallet-scope + // backfill are covered directly by + // `tc048_v007_backfills_identity_key_wallet_scope`. + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![ + FIXTURE_IDENTITY.as_slice(), + wallet.as_slice(), + identity_blob + ], + ) + .expect("insert identities"); + + // A second, bare wallet: cross-wallet isolation during the reshape is only + // observable when more than one wallet is present. + let empty = wid(EMPTY_WALLET); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![empty.as_slice()], + ) + .expect("insert empty wallet"); + + // A confirmed transaction and the UTXO it paid, on a real derived address + // so the migrated store's used-address set resolves a script to an address. + let address = first_external_info(FIXTURE_WALLET).address; + let record = one_tx_record(); + let record_blob = blob::encode(&record).expect("encode transaction record"); + let txid = [0x7Eu8; 32]; + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, 200, ?3, 1735689600, 1, ?4)", + params![ + wallet.as_slice(), + txid.as_slice(), + [0x03u8; 32].as_slice(), + record_blob + ], + ) + .expect("insert core_transactions"); + let outpoint = blob::encode_outpoint(&dashcore::OutPoint { + txid: ::from_byte_array(txid), + vout: 0, + }) + .expect("encode outpoint"); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 150000, ?3, 200, 0, 0, NULL)", + params![ + wallet.as_slice(), + outpoint, + address.script_pubkey().as_bytes() + ], + ) + .expect("insert core_utxos"); + + // An established contact carries BOTH request blobs; the reader decodes + // each one, so a NULL would fail the load rather than the migration. + let contact_id = [0xD2u8; 32]; + let request = |sender: [u8; 32], recipient: [u8; 32]| ContactRequest { + sender_id: Identifier::from(sender), + recipient_id: Identifier::from(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: 200, + created_at: 0, + }; + let outgoing = blob::encode(&request(FIXTURE_IDENTITY, contact_id)).expect("encode outgoing"); + let incoming = blob::encode(&request(contact_id, FIXTURE_IDENTITY)).expect("encode incoming"); + conn.execute( + "INSERT INTO contacts \ + (wallet_id, owner_id, contact_id, state, outgoing_request, incoming_request) \ + VALUES (?1, ?2, ?3, 'established', ?4, ?5)", + params![ + wallet.as_slice(), + FIXTURE_IDENTITY.as_slice(), + contact_id.as_slice(), + outgoing, + incoming + ], + ) + .expect("insert contacts"); +} + +/// Rebuild the committed fixture from a database created by `v4.2-dev`'s own +/// binary. Ignored by default: it needs that database, named by `V4_2_DEV_DB`. +#[test] +#[ignore] +fn regenerate_v4_2_dev_fixture() { + let source = std::env::var("V4_2_DEV_DB").expect( + "set V4_2_DEV_DB to a database created by v4.2-dev's own shipped binary \ + (see this file's module docs); it must NOT be generated by this crate", + ); + let source = PathBuf::from(source); + + let conn = rusqlite::Connection::open(&source).expect("open source database"); + let max_version: i64 = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .expect("read schema history"); + assert_eq!( + max_version, V4_2_DEV_SCHEMA_VERSION, + "V4_2_DEV_DB is not a v4.2-dev database: schema history tops out at {max_version}" + ); + seed_base_shaped_rows(&conn); + conn.execute_batch("VACUUM;").expect("vacuum"); + drop(conn); + + std::fs::copy(&source, fixture_path()).expect("copy fixture into place"); +} + +/// Always-run guard keeping the committed fixture honest: it must still look +/// like a `v4.2-dev` database rather than something this branch produced. +#[test] +fn v4_2_dev_fixture_is_present_and_shaped_like_base() { + let path = fixture_path(); + assert!( + path.exists(), + "committed fixture missing at {}; regenerate with the #[ignore] test", + path.display() + ); + + let conn = rusqlite::Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("open fixture read-only"); + + let max_version: i64 = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .expect("read schema history"); + assert_eq!( + max_version, V4_2_DEV_SCHEMA_VERSION, + "fixture must sit at the published v4.2-dev schema, not a later one" + ); + + let application_id: i64 = conn + .query_row("PRAGMA application_id", [], |r| r.get(0)) + .expect("read application_id"); + assert_eq!( + application_id, UNSTAMPED_APPLICATION_ID, + "fixture must be unstamped; a stamped file did not come from v4.2-dev" + ); + + // The three tables V008 retires must still be present, or the fixture is + // not exercising the reshape at all. + for table in [ + "wallet_metadata", + "account_address_pools", + "core_derived_addresses", + ] { + let found: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |r| r.get(0), + ) + .expect("probe sqlite_master"); + assert_eq!( + found, 1, + "fixture must still carry the pre-reshape `{table}`" + ); + } + + let legacy_standard_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM account_registrations WHERE account_type = 'standard'", + [], + |r| r.get(0), + ) + .expect("count legacy standard rows"); + assert_eq!( + legacy_standard_rows, 1, + "fixture must carry the legacy `standard` row the remap rewrites" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/.gitignore b/packages/rs-platform-wallet-storage/tests/fixtures/.gitignore new file mode 100644 index 00000000000..5eaeada7098 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/fixtures/.gitignore @@ -0,0 +1,4 @@ +# SQLite WAL/SHM side files produced when a test opens a committed fixture. +*.db-wal +*.db-shm +*.db-journal diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/v4_2_dev_migrated.db b/packages/rs-platform-wallet-storage/tests/fixtures/v4_2_dev_migrated.db new file mode 100644 index 00000000000..2305683bc27 Binary files /dev/null and b/packages/rs-platform-wallet-storage/tests/fixtures/v4_2_dev_migrated.db differ 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 index c9223c53202..79e7ea60238 100644 --- a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs +++ b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs @@ -17,6 +17,7 @@ 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 platform_wallet_storage::InsecureAncestor; use rusqlite::ErrorCode; /// Classify a converted `PersistenceError` to its `PersistenceErrorKind`. @@ -116,6 +117,39 @@ fn tc_code_004_b_constraint_variants_map_to_constraint_kind() { } } +/// Identity-slot uniqueness is enforced in Rust rather than by a SQL +/// constraint, so its variants have to claim the `Constraint` kind +/// explicitly — they are caller-data violations like any FK breach. +#[test] +fn tc_code_004_b_identity_index_variants_map_to_constraint_kind() { + let cases: Vec<(&str, WalletStorageError)> = vec![ + ( + "IdentityIndexConflict", + WalletStorageError::IdentityIndexConflict { + wallet_id: [0xAA; 32], + identity_index: 1, + existing: [0xBB; 32], + incoming: [0xCC; 32], + }, + ), + ( + "WalletlessIdentityIndex", + WalletStorageError::WalletlessIdentityIndex { + identity_id: [0xDD; 32], + identity_index: 2, + }, + ), + ]; + for (label, err) in cases { + assert!(!err.is_transient(), "{label}: must not be transient"); + assert_eq!( + kind_of(err), + PersistenceErrorKind::Constraint, + "{label}: trait-boundary kind must be Constraint" + ); + } +} + /// 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. @@ -157,6 +191,13 @@ fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { source: std::io::Error::other("io"), }, ), + ( + "InsecureParentDir", + WalletStorageError::InsecureParentDir { + ancestor: std::path::PathBuf::from("/opt/dash"), + reason: InsecureAncestor::WritableWithoutSticky { mode: 0o777 }, + }, + ), ( "WalletNotFound", WalletStorageError::WalletNotFound { @@ -176,7 +217,10 @@ fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { ), ( "InvalidWalletIdLength", - WalletStorageError::InvalidWalletIdLength { actual: 12 }, + WalletStorageError::InvalidWalletIdLength { + column: "wallets.wallet_id", + actual: 12, + }, ), ( "ConfigInvalid", @@ -203,6 +247,14 @@ fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { limit_bytes: 0, }, ), + ( + "AssetLockStatusMismatch", + WalletStorageError::AssetLockStatusMismatch { + outpoint: "txid:0".into(), + typed_status: "built".into(), + blob_status: "consumed".into(), + }, + ), ( "IntegerOverflow", WalletStorageError::IntegerOverflow { @@ -217,6 +269,44 @@ fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { path: PathBuf::from("/tmp/x"), }, ), + ( + "ReadOnlyRecoveryMode", + WalletStorageError::ReadOnlyRecoveryMode { operation: "store" }, + ), + ( + "RehydrationEnsureDerivedFailed", + WalletStorageError::RehydrationEnsureDerivedFailed { index: 42 }, + ), + ( + "RehydrationGapLimitRefillTooLarge", + WalletStorageError::RehydrationGapLimitRefillTooLarge { + refill_target: 300_000, + already_generated: 20, + implied: 299_980, + cap: 250_000, + }, + ), + ( + "RehydrationGapLimitFailed", + WalletStorageError::RehydrationGapLimitFailed { + source: key_wallet::error::Error::WatchOnly, + }, + ), + ( + "UsedAddressOwnerConflict", + WalletStorageError::UsedAddressOwnerConflict { + address: "yaddr".into(), + pool_owner: "Standard[0]".into(), + utxo_owner: "CoinJoin[0]".into(), + }, + ), + ( + "UnownedIdentityHasRegistrationIndex", + WalletStorageError::UnownedIdentityHasRegistrationIndex { + identity_id: [0xEF; 32], + identity_index: 3, + }, + ), ]; for (label, err) in fatal_cases { diff --git a/packages/rs-platform-wallet-storage/tests/secrets_api.rs b/packages/rs-platform-wallet-storage/tests/secrets_api.rs index aab8ab8d57c..a163fcbf4bf 100644 --- a/packages/rs-platform-wallet-storage/tests/secrets_api.rs +++ b/packages/rs-platform-wallet-storage/tests/secrets_api.rs @@ -13,10 +13,18 @@ use keyring_core::api::CredentialStoreApi; use keyring_core::{Error as KeyringError, Result as KeyringResult}; use platform_wallet_storage::secrets::{ EncryptedFileStore, SecretBytes, SecretStore, SecretStoreError, SecretString, WalletId, - SERVICE_PREFIX, + MAX_PASSPHRASE_LEN, MAX_SECRET_LEN, SERVICE_PREFIX, }; fn vault_path(dir: &Path) -> PathBuf { + // `open` refuses a group/other-writable parent dir; a umask-0002 + // tempdir lands at 0o775, so tighten it to 0o700 first. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .expect("tighten vault parent dir to 0o700 so open() passes the perm check"); + } dir.join("vault.pwsvault") } @@ -169,3 +177,133 @@ fn wrapper_debug_is_redacted() { let s = SecretString::new("PLAINTEXTNEEDLE"); assert!(!format!("{s:?}").contains("PLAINTEXT")); } + +/// SECRETS.md (the on-disk vault is "explicitly attacker-controllable", +/// defenses must "fail closed", error doc: "malformed vault file ... +/// truncated header"). A garbage / truncated / empty / non-UTF-8 vault +/// fed through the FULL `EncryptedFileStore::open` integration path +/// (`read_vault_at` -> `Vec::with_capacity(len)` -> `format::deserialize`) +/// must surface a clean typed `MalformedVault` and NEVER panic. The +/// `format.rs` unit tests exercise `deserialize` in isolation; they do +/// not prove the file-open seam (perms check, size cap, allocation, +/// take()) is wired to the same clean-error outcome. +#[cfg(unix)] +#[test] +fn garbage_vault_file_fails_closed_at_open_no_panic() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let cases: &[(&str, &[u8])] = &[ + ("empty", b""), + ("ascii-garbage", b"this is not a vault at all"), + ("truncated-json", b"{\"version\":1,\"kdf\":{\"id\":1,"), + ("non-utf8", &[0xff, 0xfe, 0x00, 0x01, 0x80, 0x80]), + ("json-but-not-a-vault", b"{\"hello\":\"world\"}"), + ]; + for (name, bytes) in cases { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + fs::write(&path, bytes).unwrap(); + // Match the resident-vault perm precondition so the failure is + // attributable to parsing, not to the (separately tested) perm + // refusal. + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("garbage vault must fail to open"); + assert!( + matches!(err, SecretStoreError::MalformedVault), + "case `{name}`: expected MalformedVault, got {err:?}" + ); + // The clean error must not echo the offending input bytes. + let rendered = format!("{err}"); + assert!( + !rendered.contains("not a vault") && !rendered.contains("hello"), + "case `{name}`: error leaked input bytes: {rendered}" + ); + } +} + +/// SECRETS.md: an unknown/rolled-forward `format_version` is refused +/// fail-closed through the file-open seam, distinct from a malformed +/// body. The format.rs unit test proves the parser; this proves the +/// `open()` path preserves the distinction end to end. +#[cfg(unix)] +#[test] +fn unknown_version_vault_is_refused_at_open() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + // A structurally JSON document whose `version` is in the future: + // the lax probe reads it, then the version gate rejects it before + // any KDF/AEAD work. + fs::write(&path, br#"{"version":999,"extra":"tolerated-by-probe"}"#).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("unknown version must fail to open"); + assert!( + matches!(err, SecretStoreError::VersionUnsupported { found: 999 }), + "expected VersionUnsupported{{999}}, got {err:?}" + ); +} + +/// The passphrase ceiling is part of the public contract, not an +/// internal detail: `MAX_PASSPHRASE_LEN` is re-exported, the boundary is +/// inclusive, and a violation surfaces as the typed +/// `PassphraseTooLong` carrying lengths only — never the passphrase. +#[test] +fn passphrase_cap_is_public_typed_and_leak_free() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + + // At the cap: accepted, and the vault it opens is fully usable. + let store = EncryptedFileStore::open(&path, SecretString::new("p".repeat(MAX_PASSPHRASE_LEN))) + .expect("a passphrase exactly at MAX_PASSPHRASE_LEN must be accepted"); + let w = WalletId::from([7; 32]); + store + .build(&service(w), "seed", None) + .unwrap() + .set_secret(b"still works") + .unwrap(); + drop(store); + + // Past it: refused, typed, and the message carries no plaintext. + let needle = "PLAINTEXTNEEDLE".repeat(MAX_PASSPHRASE_LEN / 15 + 1); + let err = EncryptedFileStore::open(&path, SecretString::new(needle.clone())) + .expect_err("a passphrase past MAX_PASSPHRASE_LEN must be refused"); + assert!( + matches!(err, SecretStoreError::PassphraseTooLong { found, max } + if found == needle.len() && max == MAX_PASSPHRASE_LEN), + "got {err:?}" + ); + let rendered = format!("{err} {err:?}"); + assert!( + !rendered.contains("PLAINTEXTNEEDLE"), + "error leaked the passphrase: {rendered}" + ); +} + +/// The two public size ceilings stay put, pinned by value. +/// +/// Both are load-bearing rows of the locked-memory budget documented at +/// `MAX_SECRET_LEN`, and both are public API — so a change to either is a +/// change to what callers may store, not an implementation detail. Spelt +/// as literals rather than as page arithmetic: they are product ceilings +/// chosen to cover real secrets cheaply, and deriving them from a page +/// size is what previously tied them to one host's idea of a page. +/// +/// The page size remains an assumption about the host, so the store +/// construction below asserts it holds here: a host with larger pages +/// cannot open a store at all, which is what stops the budget these +/// ceilings belong to from silently describing nobody. +#[test] +fn public_size_ceilings_stay_pinned() { + assert_eq!(MAX_SECRET_LEN, 8176); + assert_eq!(MAX_PASSPHRASE_LEN, 4080); + + let dir = tempfile::tempdir().unwrap(); + let _store = open(dir.path()); +} diff --git a/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs b/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs index 23cc10d582e..d6e8a944347 100644 --- a/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs +++ b/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs @@ -1,15 +1,21 @@ -//! Build-only proof (M-S4) that the default build (no flag passed) -//! reaches `EncryptedFileStore` as a public type. +//! Build-only proof (M-S4) that the `secrets` public surface is reachable +//! from the crate root, not only by a deep module path. //! -//! With `secrets` in the default feature set, importing the type from -//! the crate root without enabling any feature flag is the assertion. -//! The test body never exercises a backend — it only compiles. +//! Naming every re-export in a body that never runs a backend is the whole +//! assertion: it fails to COMPILE if a type stops being re-exported at +//! `platform_wallet_storage::secrets`. +//! +//! It does NOT prove that `secrets` is default-on, and cannot: the +//! dev-dependency this file compiles under sets `default-features = false` and +//! then lists `secrets` explicitly, so the feature is on by request here, not +//! by default. Proving the shipped default set would take a separate crate or +//! a CI step that builds with real defaults. #![cfg(feature = "secrets")] use platform_wallet_storage::secrets::{ default_credential_store, EncryptedFileStore, SecretBytes, SecretStoreError, SecretString, - WalletId, SERVICE_PREFIX, + WalletId, MAX_PLAINTEXT_LEN, MIN_PASSPHRASE_LEN, SERVICE_PREFIX, }; #[test] @@ -23,6 +29,9 @@ fn default_build_exposes_secrets_surface() { } let _ = _accepts_path as fn(_, _) -> _; let _ = SERVICE_PREFIX.len(); + // The Tier-2 public consts are re-exported on the default build. + let _ = MAX_PLAINTEXT_LEN; + let _ = MIN_PASSPHRASE_LEN; let _ = std::mem::size_of::(); let _ = std::mem::size_of::(); let _ = std::mem::size_of::(); diff --git a/packages/rs-platform-wallet-storage/tests/secrets_mock_store_test_util.rs b/packages/rs-platform-wallet-storage/tests/secrets_mock_store_test_util.rs new file mode 100644 index 00000000000..cfb7fa71d61 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/secrets_mock_store_test_util.rs @@ -0,0 +1,66 @@ +//! Downstream proof that the `test-util` feature reaches `SecretStore::file_mock` +//! from OUTSIDE the crate (#4111). +//! +//! The unit tests all run under `cfg(test)`, which satisfies the +//! `cfg(any(test, feature = "test-util"))` gate on its own — so a misspelt +//! feature name in that gate would still pass them while breaking the one +//! caller the feature exists for (a downstream suite, which never gets +//! `cfg(test)` for THIS crate). Compiling this file against the crate as an +//! ordinary dependency is that assertion; the round-trip below then proves the +//! mock store is a real, working store and not just a name that links. + +#![cfg(all(feature = "secrets", feature = "test-util"))] + +use platform_wallet_storage::secrets::{SecretBytes, SecretStore, SecretString, WalletId}; + +/// Tighten the umask-0002 tempdir (0o775) to 0o700 so it passes the vault's +/// parent-dir permission check, then return a vault path inside it. +fn secure_vault_path(dir: &std::path::Path) -> std::path::PathBuf { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + dir.join("vault.pwsvault") +} + +/// A downstream caller swaps `file` → `file_mock` at construction and every +/// later call is transparently cheap, with no other signature change. The +/// floor-params assertions live in the crate's unit tests (the encoded +/// `KdfParams` are `pub(crate)`); what this pins is the public surface a +/// consumer actually touches. +#[test] +fn file_mock_is_reachable_and_round_trips_from_downstream() { + let dir = tempfile::tempdir().unwrap(); + let store = SecretStore::file_mock( + secure_vault_path(dir.path()), + SecretString::new("test-password"), + ) + .expect("mock vault opens"); + + let wallet = WalletId::from([7u8; 32]); + let pw = SecretString::new("object-pw"); + + store + .set_secret( + &wallet, + "seed", + &SecretBytes::from_slice(b"SEED"), + Some(&pw), + ) + .unwrap(); + assert_eq!( + store + .get_secret(&wallet, "seed", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"SEED" + ); + + store.reprotect(&wallet, "seed", Some(&pw), None).unwrap(); + assert_eq!( + store.get(&wallet, "seed").unwrap().unwrap().expose_secret(), + b"SEED" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/secrets_scan.rs b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs index 68e0cf48d01..9f1111f2899 100644 --- a/packages/rs-platform-wallet-storage/tests/secrets_scan.rs +++ b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs @@ -10,17 +10,13 @@ //! `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. +//! Scope and blind spots: this is a column/comment NAMING scan, not a +//! value-content scan — it cannot see the bytes a serialized value +//! carries. Value-level safety is a separate guarantee via the sealed +//! `PersistableBlob` trait in `src/sqlite/schema/blob.rs`. Files outside +//! `schema/` define no columns and are not scanned; `src/secrets/` is +//! exempt by design and covered by its own `tests/secrets_guard.rs`. +//! Legitimate uses inside doc comments are allow-listed via `ALLOWLIST`. use std::path::Path; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs b/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs new file mode 100644 index 00000000000..ba2eb55f0a7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs @@ -0,0 +1,148 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Genesis-rescan regression for UTXO attribution when a freshly-derived +//! gap-limit-edge address has no persisted pool row. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::AddressInfo; +use platform_wallet::changeset::AccountRegistrationEntry; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::core_state; +use platform_wallet_storage::LoadCtx; + +fn manifest_for(wallet: &Wallet) -> Vec { + wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect() +} + +/// The LAST address in the wallet's Standard BIP44 external pool — the +/// gap-limit-edge address, the one most likely to be a fresh extension and +/// thus the worst case for the retired attribution race. +fn wallet_and_gap_limit_edge_address(seed_byte: u8) -> (Wallet, AddressInfo) { + use key_wallet::account::AccountType; + use key_wallet::managed_account::address_pool::AddressPoolType; + + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + + for managed in info.all_managed_accounts() { + let account_type = managed.managed_account_type().to_account_type(); + if !matches!(account_type, AccountType::Standard { index: 0, .. }) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let mut infos: Vec = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|address| address.index); + return (wallet, infos.pop().unwrap()); + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +fn utxo_at(addr: &dashcore::Address, vout: u32, value: u64) -> key_wallet::Utxo { + use dashcore::hashes::Hash; + key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0x7E; 32]), + vout, + }, + txout: dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr.clone(), + height: 7, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +/// A UTXO without a pool row follows the real restart path into the first +/// funds account with its exact balance. +#[test] +fn utxo_on_fresh_gap_limit_address_rehydrates_under_first_funds_account() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xD1); + ensure_wallet_meta(&persister, &w); + + let (wallet, edge) = wallet_and_gap_limit_edge_address(0x55); + let addr = edge.address.clone(); + let utxo = utxo_at(&addr, 0, 777_000); + let outpoint = utxo.outpoint; + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("a UTXO on a fresh gap-limit address must persist, not abort"); + drop(persister); + + let reopened = platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(&path), + ) + .expect("reopen persister"); + let conn = reopened.lock_conn_for_test(); + let (core, utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .expect("load state"); + drop(conn); + + assert!( + utxo_accounts.is_empty(), + "the missing pool row must exercise the unattributed fallback" + ); + + let mut managed = ManagedWalletInfo::from_wallet(&wallet, 1); + platform_wallet_storage::sqlite::rehydrate::apply_persisted_core_state( + &mut managed, + &manifest_for(&wallet), + &core, + &utxo_accounts, + &Default::default(), + &LoadCtx::strict(), + ) + .expect("rehydration must apply the unattributed UTXO"); + + let first_funds = managed.accounts.all_funding_accounts().remove(0); + assert!( + first_funds.utxos.contains_key(&outpoint), + "the UTXO must land in the first funds account" + ); + assert_eq!(first_funds.balance.total(), 777_000); + assert_eq!(WalletInfoInterface::balance(&managed).total(), 777_000); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs new file mode 100644 index 00000000000..8a3fc4257ed --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs @@ -0,0 +1,336 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `schema::accounts::load_state` reads `account_registrations` rows back +//! into a keyless [`AccountRegistrationEntry`] manifest, bit-exact, +//! fail-hard on a corrupt blob, and never mints a `Wallet`. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::account::{AccountType, StandardAccountType}; +use platform_wallet::changeset::{ + AccountRegistrationEntry, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::{accounts, blob}; +use platform_wallet_storage::{LoadCtx, LoadSite, SqlitePersister, WalletStorageError}; + +/// A distinct extended public key per `seed` byte, so a round-trip test can +/// tell entries apart instead of asserting against one shared xpub. +fn xpub_from_seed(seed: u8) -> key_wallet::bip32::ExtendedPubKey { + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + let w = Wallet::from_seed_bytes( + [seed; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("wallet"); + w.accounts + .all_accounts() + .first() + .expect("at least one account") + .account_xpub +} + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +/// Registrations round-trip bit-exact, in the reader's deterministic order +/// (`account_type` label ascending), with each entry keeping its OWN xpub. +#[test] +fn a1_account_registrations_roundtrip() { + let (persister, _tmp, path) = fresh_persister(); + use platform_wallet::changeset::PlatformWalletPersistence; + let w = wid(0xA1); + ensure_wallet_meta(&persister, &w); + + // Distinct xpubs so the round-trip proves each entry keeps its own key, + // not just that *some* xpub survives. + let standard_xpub = xpub_from_seed(7); + let idreg_xpub = xpub_from_seed(8); + assert_ne!(standard_xpub, idreg_xpub, "fixtures must differ"); + + let entries = vec![ + AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + account_xpub: standard_xpub, + }, + AccountRegistrationEntry { + account_type: AccountType::IdentityRegistration, + account_xpub: idreg_xpub, + }, + ]; + let cs = PlatformWalletChangeSet { + account_registrations: entries.clone(), + ..Default::default() + }; + persister.store(w, cs).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let manifest = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state") + .ecdsa; + drop(conn); + + assert_eq!(manifest.len(), 2, "all rows must be returned"); + // Reader orders by `account_type` label: 'identity_registration' sorts + // before 'standard_bip44', so the manifest is deterministically ordered. + assert!( + matches!(manifest[0].account_type, AccountType::IdentityRegistration), + "identity_registration must sort first, got {:?}", + manifest[0].account_type + ); + assert_eq!( + manifest[0].account_xpub, idreg_xpub, + "IdentityRegistration must keep its own xpub" + ); + assert!( + matches!( + manifest[1].account_type, + AccountType::Standard { index: 0, .. } + ), + "standard_bip44 must sort second, got {:?}", + manifest[1].account_type + ); + assert_eq!( + manifest[1].account_xpub, standard_xpub, + "Standard must keep its own xpub" + ); +} + +/// An empty wallet yields an empty manifest, not an error. +#[test] +fn a1_empty_manifest_is_ok() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA2); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let manifest = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state") + .ecdsa; + drop(conn); + assert!(manifest.is_empty()); +} + +/// A corrupt `account_xpub_bytes` blob is a typed hard error, never a +/// silent skip. +#[test] +fn a1_corrupt_blob_is_hard_error() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA3); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'standard_bip44', 0, X'00')", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + } + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let result = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()); + drop(conn); + assert!( + matches!(result, Err(WalletStorageError::BincodeDecode { .. })), + "corrupt account_xpub_bytes must be a typed BincodeDecode; got {result:?}" + ); +} + +/// A standard-account registration with a distinguishable xpub. +fn standard(index: u32, variant: StandardAccountType, xpub_seed: u8) -> AccountRegistrationEntry { + AccountRegistrationEntry { + account_type: AccountType::Standard { + index, + standard_account_type: variant, + }, + account_xpub: xpub_from_seed(xpub_seed), + } +} + +fn store_registrations( + persister: &SqlitePersister, + wallet: WalletId, + entries: &[AccountRegistrationEntry], +) { + let cs = PlatformWalletChangeSet { + account_registrations: entries.to_vec(), + ..Default::default() + }; + persister.store(wallet, cs).expect("store registrations"); +} + +/// Plant a raw row under an arbitrary `account_type` label. This is how a +/// pre-split `standard` row exists in a database migrated past the split: no +/// writer path can produce one, because the label is derived from the typed +/// `AccountType`. +fn plant_registration( + persister: &SqlitePersister, + wallet: &WalletId, + label: &str, + index: i64, + entry: &AccountRegistrationEntry, +) { + let payload = blob::encode(entry).expect("encode registration"); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![wallet.as_slice(), label, index, payload], + ) + .expect("plant a registration row"); +} + +fn ecdsa_manifest( + persister: &SqlitePersister, + wallet: &WalletId, + ctx: &LoadCtx, +) -> Vec { + let conn = persister.lock_conn_for_test(); + accounts::load_state(&conn, wallet, ctx) + .expect("load_state") + .ecdsa +} + +/// A pre-split `standard` row and the precise row a later save inserted beside +/// it are ONE account, and the reader returns it once. The writer cannot merge +/// them — its upsert keys on `account_type`, so the precise label is a +/// different primary key — so the reader owns the reconciliation. Emitting the +/// account twice would make this crate's manifest depend on a consumer it does +/// not own to dedup. +#[test] +fn a1_legacy_standard_row_collapses_into_its_precise_sibling() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA4); + ensure_wallet_meta(&persister, &w); + + let entry = standard(0, StandardAccountType::BIP44Account, 7); + store_registrations(&persister, w, std::slice::from_ref(&entry)); + plant_registration(&persister, &w, "standard", 0, &entry); + + assert_eq!( + ecdsa_manifest(&persister, &w, &LoadCtx::strict()), + vec![entry], + "the forked pair is one account and must collapse to the precise row" + ); +} + +/// The pair is only a duplicate while both rows agree. The legacy row is never +/// updated — `DO UPDATE` targets the precise row alone — so a persisted xpub +/// change leaves two DIFFERENT accounts at one index. That is drift, not a +/// duplicate, and it goes through the same typed, policy-governed site as +/// every other blob-versus-column disagreement instead of being silently +/// resolved by preference. +#[test] +fn a1_legacy_standard_row_that_contradicts_its_sibling_is_typed_drift() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA5); + ensure_wallet_meta(&persister, &w); + + let current = standard(0, StandardAccountType::BIP44Account, 7); + let stale = standard(0, StandardAccountType::BIP44Account, 8); + assert_ne!( + current.account_xpub, stale.account_xpub, + "fixtures must differ" + ); + store_registrations(&persister, w, std::slice::from_ref(¤t)); + plant_registration(&persister, &w, "standard", 0, &stale); + + let err = { + let conn = persister.lock_conn_for_test(); + accounts::load_state(&conn, &w, &LoadCtx::strict()) + .expect_err("a contradicting pair must be fatal under Strict") + }; + assert!( + matches!(err, WalletStorageError::AccountRegistrationEntryMismatch), + "expected AccountRegistrationEntryMismatch, got {err:?}" + ); + + let ctx = LoadCtx::recovery(); + assert_eq!( + ecdsa_manifest(&persister, &w, &ctx), + vec![current], + "recovery keeps the row the writer maintains, not the stale projection" + ); + let degradation = ctx.degradation(); + assert_eq!( + degradation.by_site.get(&LoadSite::AccountRegistrationDrift), + Some(&1), + "the tolerated pair must be counted at its own site" + ); + assert_eq!( + degradation.by_site.len(), + 1, + "nothing else may be tolerated: {:?}", + degradation.by_site + ); +} + +/// The common case — no pre-split row anywhere — keeps every registration and +/// its order. Reconciliation is an edge case and must not leak into the path +/// every post-split database takes. +#[test] +fn a1_wallet_without_a_legacy_row_keeps_every_registration() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA6); + ensure_wallet_meta(&persister, &w); + + let bip44 = standard(0, StandardAccountType::BIP44Account, 7); + let bip32 = standard(0, StandardAccountType::BIP32Account, 8); + let idreg = AccountRegistrationEntry { + account_type: AccountType::IdentityRegistration, + account_xpub: xpub_from_seed(9), + }; + store_registrations( + &persister, + w, + &[bip44.clone(), bip32.clone(), idreg.clone()], + ); + + // Label order: 'identity_registration' < 'standard_bip32' < 'standard_bip44'. + assert_eq!( + ecdsa_manifest(&persister, &w, &LoadCtx::strict()), + vec![idreg, bip32, bip44], + "a wallet with no legacy row must be returned unchanged" + ); +} + +/// BIP44 index 0 and BIP32 index 0 are DIFFERENT accounts that the pre-split +/// schema could not tell apart — splitting the label is what stopped them +/// sharing a row. A legacy row must therefore be matched to its sibling by the +/// full typed account, not by `(account_index, key_class, identity ids)`: +/// that coarser key fuses this pair, drops a real account, and reports a fatal +/// drift for a wallet that has none. +#[test] +fn a1_legacy_standard_row_does_not_absorb_the_other_standard_variant() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA7); + ensure_wallet_meta(&persister, &w); + + let bip32 = standard(0, StandardAccountType::BIP32Account, 9); + let legacy_bip44 = standard(0, StandardAccountType::BIP44Account, 7); + store_registrations(&persister, w, std::slice::from_ref(&bip32)); + plant_registration(&persister, &w, "standard", 0, &legacy_bip44); + + assert_eq!( + ecdsa_manifest(&persister, &w, &LoadCtx::strict()), + vec![legacy_bip44, bip32], + "distinct standard variants at one index must both survive" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs b/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs new file mode 100644 index 00000000000..fda58682e27 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs @@ -0,0 +1,146 @@ +#![allow(clippy::field_reassign_with_default)] + +//! The status-filtered asset-lock reader excludes terminal `Consumed` +//! rows so a spent one-shot lock never resurrects as actionable on +//! rehydration, while the historical row stays on disk. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +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, PlatformWalletPersistence}; +use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; +use platform_wallet_storage::sqlite::schema::asset_locks; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen") +} + +fn entry(op: OutPoint, status: AssetLockStatus) -> AssetLockEntry { + AssetLockEntry { + out_point: op, + 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, + proof: None, + } +} + +fn op(b: u8) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([b; 32]), + vout: 0, + } +} + +/// Store a mix including one terminal `Consumed`. After reopen: the +/// `Consumed` row is still on disk, is absent from the filtered +/// rehydration feed, and non-terminal rows survive. +#[test] +fn rt4_consumed_excluded_from_rehydration_feed() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA4); + ensure_wallet_meta(&persister, &w); + + let op_built = op(0x10); + let op_cl = op(0x11); + let op_consumed = op(0x12); + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks + .insert(op_built, entry(op_built, AssetLockStatus::Built)); + cs.asset_locks + .insert(op_cl, entry(op_cl, AssetLockStatus::ChainLocked)); + cs.asset_locks + .insert(op_consumed, entry(op_consumed, AssetLockStatus::Consumed)); + persister + .store( + w, + platform_wallet::changeset::PlatformWalletChangeSet { + asset_locks: Some(cs), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + + // (a) the Consumed row is still physically on disk. + let consumed_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM asset_locks WHERE wallet_id = ?1 AND status = 'consumed'", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(consumed_rows, 1, "Consumed row must persist on disk"); + + // Unfiltered reader still returns the Consumed entry... + let unfiltered = + asset_locks::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()).unwrap(); + let all_ops: Vec<_> = unfiltered + .values() + .flat_map(|m| m.keys().copied()) + .collect(); + assert!( + all_ops.contains(&op_consumed), + "unfiltered load_state must still see Consumed (historical)" + ); + + // (b)+(c) the filtered rehydration feed excludes Consumed, keeps + // the rest. + let feed = asset_locks::load_unconsumed(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .unwrap(); + drop(conn); + let feed_ops: Vec<_> = feed.values().flat_map(|m| m.keys().copied()).collect(); + assert!( + !feed_ops.contains(&op_consumed), + "Consumed must NOT resurrect in the rehydration feed" + ); + assert!(feed_ops.contains(&op_built), "Built must survive"); + assert!(feed_ops.contains(&op_cl), "ChainLocked must survive"); + assert_eq!(feed_ops.len(), 2); +} + +/// An all-consumed wallet yields an empty rehydration feed, no error. +#[test] +fn a2_all_consumed_yields_empty_feed() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA5); + ensure_wallet_meta(&persister, &w); + let o = op(0x20); + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks + .insert(o, entry(o, AssetLockStatus::Consumed)); + persister + .store( + w, + platform_wallet::changeset::PlatformWalletChangeSet { + asset_locks: Some(cs), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let feed = asset_locks::load_unconsumed(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .unwrap(); + drop(conn); + assert!(feed.is_empty(), "all-consumed wallet → empty feed"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs index 085169cd2d4..5ed597bbffb 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs @@ -12,7 +12,7 @@ use platform_wallet_storage::{ /// 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 tmp = common::secure_tempdir().unwrap(); let path = tmp.path().join("w.db"); let cfg = SqlitePersisterConfig::new(&path); let dir = cfg.auto_backup_dir.clone().unwrap(); @@ -47,10 +47,52 @@ fn tc051_pre_delete_backup_taken() { ); } +#[cfg(unix)] +#[test] +fn auto_backup_directory_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let backup_dir = tmp.path().join("nested").join("auto"); + let cfg = SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(backup_dir.clone())); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xE4); + ensure_wallet_meta(&persister, &w); + + persister.delete_wallet(w).expect("delete with auto-backup"); + + let mode = std::fs::metadata(&backup_dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "auto-backup directory must be owner-only"); +} + +#[cfg(unix)] +#[test] +fn auto_backup_rejects_replaceable_ancestor() { + use std::os::unix::fs::PermissionsExt; + + let tmp = common::secure_tempdir().unwrap(); + let replaceable = tmp.path().join("replaceable"); + std::fs::create_dir(&replaceable).unwrap(); + std::fs::set_permissions(&replaceable, std::fs::Permissions::from_mode(0o777)).unwrap(); + let backup_dir = replaceable.join("backups"); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(backup_dir)); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0x35); + ensure_wallet_meta(&persister, &w); + + let result = persister.delete_wallet(w); + assert!( + matches!(result, Err(WalletStorageError::InsecureParentDir { .. })), + "replaceable backup ancestor must be rejected; got {result:?}" + ); +} + /// 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 tmp = common::secure_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(); @@ -70,7 +112,7 @@ fn tc052_delete_wallet_auto_backup_disabled() { let conn = persister.lock_conn_for_test(); let n: i64 = conn .query_row( - "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| row.get(0), ) @@ -87,7 +129,7 @@ fn tc052_delete_wallet_auto_backup_disabled() { /// CI containers. #[test] fn tc054_unwritable_auto_backup_dir() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_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(); @@ -106,7 +148,7 @@ fn tc054_unwritable_auto_backup_dir() { let conn = persister.lock_conn_for_test(); let n: i64 = conn .query_row( - "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| row.get(0), ) @@ -144,3 +186,131 @@ fn tc055_auto_backups_subject_to_retention() { assert_eq!(report.kept, 2); assert_eq!(report.removed.len(), 3); } + +/// Prune orders by the EMBEDDED filename timestamp, not mtime (proven by +/// giving older files newer mtimes). With `keep_last_n = 1` it evicts even +/// a pre-delete safety backup when that backup is not the newest by +/// embedded timestamp: the auto dir is not a protected vault, so operators +/// must size retention above the rollback horizon they care about. +#[test] +fn tc056_aggressive_prune_evicts_safety_backup_and_orders_by_embedded_ts() { + let (persister, _tmp, _path) = fresh_persister(); + let dir = persister.config_for_test().auto_backup_dir.clone().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + + let stamp = |hours_ago: i64| { + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::hours(hours_ago)) + .unwrap() + .format("%Y%m%dT%H%M%SZ") + .to_string() + }; + + // Newest by embedded timestamp: a manual backup taken AFTER the + // delete. The pre-delete safety backup is older by embedded ts. + let manual = dir.join(format!("wallet-{}.db", stamp(0))); + let safety = dir.join(format!( + "pre-delete-{}-{}.db", + hex::encode([0x11u8; 32]), + stamp(1) + )); + let old_manual = dir.join(format!("wallet-{}.db", stamp(48))); + std::fs::write(&manual, b"m").unwrap(); + std::fs::write(&safety, b"s").unwrap(); + std::fs::write(&old_manual, b"o").unwrap(); + + // Invert mtime vs embedded order: give the OLDEST-by-embedded-ts + // file the NEWEST mtime. If prune (wrongly) sorted by mtime, it + // would keep `old_manual`; sorting by the embedded token keeps + // `manual`. This deterministically exercises the embedded-timestamp + // path rather than the mtime fallback. + let now = std::time::SystemTime::now(); + let hour = std::time::Duration::from_secs(3600); + filetime::set_file_mtime(&old_manual, filetime::FileTime::from_system_time(now)).unwrap(); + filetime::set_file_mtime(&safety, filetime::FileTime::from_system_time(now - hour)).unwrap(); + filetime::set_file_mtime( + &manual, + filetime::FileTime::from_system_time(now - hour * 2), + ) + .unwrap(); + + let report = persister + .prune_backups( + &dir, + platform_wallet_storage::RetentionPolicy { + keep_last_n: Some(1), + max_age: None, + }, + ) + .unwrap(); + + assert_eq!(report.kept, 1, "keep_last_n = 1 keeps exactly one file"); + assert_eq!(report.removed.len(), 2); + // Embedded-ts ordering kept the newest-by-token file (`manual`), + // NOT the newest-by-mtime file (`old_manual`). + assert!( + manual.exists(), + "newest-by-embedded-timestamp file must survive keep_last_n = 1" + ); + assert!( + !old_manual.exists(), + "an old file with a fresh mtime must NOT be treated as newest" + ); + // The safety backup is NOT special-cased: aggressive retention + // evicts it. Operators must size retention above the rollback + // horizon they care about. + assert!( + !safety.exists(), + "pre-delete safety backup is evicted by keep_last_n = 1 when not newest \ + (auto dir is not a protected vault)" + ); +} + +/// `keep_last_n` is a FLOOR, not a ceiling: with both `keep_last_n` and +/// `max_age` set, a file beyond the N newest but still within `max_age` must +/// be KEPT (the union of the two policies), and only files failing BOTH are +/// evicted. Regression guard for the count-caps-the-age-window bug. +#[test] +fn keep_last_n_is_a_floor_not_a_ceiling_with_max_age() { + let (persister, _tmp, _path) = fresh_persister(); + let dir = persister.config_for_test().auto_backup_dir.clone().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + + let stamp = |hours_ago: i64| { + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::hours(hours_ago)) + .unwrap() + .format("%Y%m%dT%H%M%SZ") + .to_string() + }; + // Newest (0h) kept by the floor; the 1h file is beyond the floor but + // within the 2h window (kept by age); the 48h file fails both (evicted). + let newest = dir.join(format!("wallet-{}.db", stamp(0))); + let within_age = dir.join(format!("wallet-{}.db", stamp(1))); + let too_old = dir.join(format!("wallet-{}.db", stamp(48))); + for p in [&newest, &within_age, &too_old] { + std::fs::write(p, b"x").unwrap(); + } + + let report = persister + .prune_backups( + &dir, + platform_wallet_storage::RetentionPolicy { + keep_last_n: Some(1), + max_age: Some(std::time::Duration::from_secs(2 * 3600)), + }, + ) + .unwrap(); + + assert_eq!( + report.kept, 2, + "floor (1) + within-age (1) must both survive" + ); + assert_eq!(report.removed.len(), 1); + assert!(newest.exists(), "the newest file is kept by the floor"); + assert!( + within_age.exists(), + "a within-max_age file beyond the floor must NOT be evicted by the count" + ); + assert!(!too_old.exists(), "a file failing both policies is evicted"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs b/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs index 5ec6d190b04..ddefaa7d1a6 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs @@ -101,10 +101,61 @@ fn tc035_restore_roundtrip() { assert_eq!(h, 5); } +/// Sibling databases restored into one shared auto-backup dir must each get +/// their own pre-restore backup filename. Before the source stem was +/// embedded, `pre-restore-.db` collided whenever two restores landed in +/// the same one-second timestamp and the second one failed. +#[test] +fn sibling_dbs_get_distinct_pre_restore_backup_names() { + let tmp = common::secure_tempdir().unwrap(); + let backup_dir = tmp.path().join("backups"); + let source_dir = tmp.path().join("source"); + fs::create_dir(&source_dir).unwrap(); + + let mut dests = Vec::new(); + let mut restore_src = None; + for db_name in ["det-mainnet.sqlite", "det-testnet.sqlite"] { + let path = tmp.path().join(db_name); + let persister = + SqlitePersister::open(platform_wallet_storage::SqlitePersisterConfig::new(&path)) + .unwrap(); + seed_one_row(&persister, &wid(0xD7)); + if restore_src.is_none() { + restore_src = Some(persister.backup_to(&source_dir).unwrap()); + } + dests.push(path); + } + let restore_src = restore_src.expect("one destination seeded the restore source"); + + for dest in &dests { + SqlitePersister::restore_from(dest, &restore_src, Some(&backup_dir)) + .unwrap_or_else(|e| panic!("restore into {} must not collide: {e}", dest.display())); + } + + let mut names: Vec = fs::read_dir(&backup_dir) + .expect("read backup dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("pre-restore-") && n.ends_with(".db")) + .collect(); + names.sort(); + assert_eq!( + names.len(), + 2, + "one pre-restore backup per destination: {names:?}" + ); + for stem in ["det-mainnet", "det-testnet"] { + assert!( + names.iter().any(|n| n.contains(stem)), + "no backup names {stem} among {names:?}" + ); + } +} + /// TC-036: restore source missing schema_history is rejected. #[test] fn tc036_restore_missing_schema_history() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let fake_src = tmp.path().join("empty.db"); rusqlite::Connection::open(&fake_src).unwrap(); let dest = tmp.path().join("dest.db"); @@ -116,7 +167,7 @@ fn tc036_restore_missing_schema_history() { /// TC-037: corrupt source rejected. #[test] fn tc037_restore_corrupt_source() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_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"); @@ -176,7 +227,7 @@ fn atom_004_backup_to_failure_leaves_no_junk_at_dest() { /// TC-038: prune retention AND-semantics. #[test] fn tc038_prune_and_semantics() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_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); @@ -244,7 +295,7 @@ fn atom_011_prune_report_carries_failed_removals_field() { #[cfg(unix)] fn is_root_via_probe() -> bool { use std::os::unix::fs::PermissionsExt; - let Ok(tmp) = tempfile::tempdir() else { + let Ok(tmp) = common::secure_tempdir() else { return false; }; let dir = tmp.path().join("probe"); @@ -369,7 +420,7 @@ fn tc_code_019_a_failed_removal_counts_in_kept() { return; } use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_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`. @@ -419,3 +470,40 @@ fn tc_code_019_a_failed_removal_counts_in_kept() { "kept + removed must equal total eligible (5)" ); } + +/// The persister's gated `prune_backups` and the crate-root +/// `prune_backups_in` must be one implementation, not two: the wrapper +/// adds the recovery gate and nothing else. The raw `sqlite::backup::prune` +/// is `pub(crate)` so this pair is the whole public surface. +#[test] +fn prune_backups_in_matches_the_gated_wrapper() { + let (persister, tmp, _path) = fresh_persister(); + + // Retention selects by backup file NAME, so the fixtures are written + // directly — driving `backup_to` would need a sleep per file to clear + // the whole-second timestamp granularity. + let via_free_fn = tmp.path().join("free-fn"); + let via_wrapper = tmp.path().join("wrapper"); + for dir in [&via_free_fn, &via_wrapper] { + fs::create_dir_all(dir).unwrap(); + for stamp in ["20260101T000001Z", "20260101T000002Z", "20260101T000003Z"] { + fs::write(dir.join(format!("wallet-{stamp}.db")), b"backup").unwrap(); + } + } + + let policy = RetentionPolicy::keep_last(1); + let free_fn_report = + platform_wallet_storage::prune_backups_in(&via_free_fn, policy).expect("free-fn prune"); + let wrapper_report = persister + .prune_backups(&via_wrapper, policy) + .expect("wrapper prune in strict mode"); + + assert_eq!(free_fn_report.kept, wrapper_report.kept); + assert_eq!( + free_fn_report.removed.len(), + wrapper_report.removed.len(), + "both doors must apply the same retention policy" + ); + assert_eq!(free_fn_report.kept, 1, "keep_last(1) must keep exactly one"); + assert_eq!(free_fn_report.removed.len(), 2); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_blob_roundtrip_coverage.rs b/packages/rs-platform-wallet-storage/tests/sqlite_blob_roundtrip_coverage.rs new file mode 100644 index 00000000000..19c16add348 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_blob_roundtrip_coverage.rs @@ -0,0 +1,141 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Recurrence guard for #4133: exercise **fully-populated** values of BOTH +//! enum-bearing proof variants — an `AssetLockEntry` carrying +//! `proof: Some(AssetLockProof::Chain(..))` and one carrying +//! `proof: Some(AssetLockProof::Instant(..))` — end-to-end through the public +//! `SqlitePersister::store` → reopen → `load_unconsumed` path, plus a +//! `proof: None` control. +//! +//! The original defect escaped every test because all asset-lock fixtures used +//! `proof: None`, so the internally-tagged proof enum was never round-tripped. +//! Enumerating both variants with non-default field values (not a single +//! variant, not a partial/empty one) is what makes this a guard that would +//! actually have caught the bug. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::hashes::Hash; +use dashcore::{OutPoint, Transaction, Txid}; +use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof; +use dpp::prelude::AssetLockProof; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry, PlatformWalletChangeSet}; +use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; +use platform_wallet_storage::sqlite::schema::asset_locks; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +fn reopen(path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") +} + +fn op(b: u8) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([b; 32]), + vout: 0, + } +} + +fn entry(op: OutPoint, status: AssetLockStatus, proof: Option) -> AssetLockEntry { + AssetLockEntry { + out_point: op, + transaction: Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount_duffs: 1000, + status, + proof, + } +} + +/// Store the three proof shapes (`Chain`, `Instant`, `None`), reopen, and +/// assert every one rehydrates with its proof intact. +#[test] +fn proof_bearing_asset_locks_round_trip_through_persister() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB4); + ensure_wallet_meta(&persister, &w); + + let op_chain = op(0x10); + let op_instant = op(0x11); + let op_none = op(0x12); + + let chain = AssetLockProof::Chain(ChainAssetLockProof::new(99, [0x05u8; 36])); + // Distinct, non-default field values so the round-trip proves field + // fidelity, not merely that `default() == default()`. + let instant = AssetLockProof::Instant({ + let mut p = InstantAssetLockProof::default(); + p.transaction.version = 3; + p.transaction.lock_time = 111; + p.output_index = 2; + // `default()` leaves the nested `InstantLock.inputs` empty; a real + // IS-lock always carries at least one input, so populate it to exercise + // the length-prefixed-vec encoding path a genuine proof uses. + p.instant_lock.inputs = vec![op(0xA1), op(0xA2)]; + p + }); + + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks.insert( + op_chain, + entry(op_chain, AssetLockStatus::ChainLocked, Some(chain.clone())), + ); + cs.asset_locks.insert( + op_instant, + entry( + op_instant, + AssetLockStatus::InstantSendLocked, + Some(instant.clone()), + ), + ); + cs.asset_locks + .insert(op_none, entry(op_none, AssetLockStatus::Built, None)); + + persister + .store( + w, + PlatformWalletChangeSet { + asset_locks: Some(cs), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let feed = asset_locks::load_unconsumed(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load must succeed"); + drop(conn); + + let flat: std::collections::BTreeMap> = feed + .into_values() + .flat_map(|by_op| by_op.into_iter().map(|(op, t)| (op, t.proof))) + .collect(); + + assert_eq!( + flat.get(&op_chain), + Some(&Some(chain)), + "Chain proof must survive the persister round-trip" + ); + assert_eq!( + flat.get(&op_instant), + Some(&Some(instant)), + "Instant proof must survive the persister round-trip" + ); + assert_eq!( + flat.get(&op_none), + Some(&None), + "None proof must round-trip as None" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs new file mode 100644 index 00000000000..b9a89b39eaa --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs @@ -0,0 +1,529 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Pre-read BLOB size-gate regression tests. +//! +//! Proves that each load-path BLOB reader rejects an oversize row with +//! [`WalletStorageError::BlobTooLarge`] **before** materialising the `Vec`, +//! i.e. the `length()` gate fires first. The oversize blob is planted +//! directly via raw SQL so the production encode path (which enforces the cap +//! on writes) is bypassed — simulating a tampered / corrupted local wallet DB. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use rusqlite::params; + +use platform_wallet_storage::sqlite::schema::{ + accounts, core_pool, core_state, identities, identity_keys, +}; +use platform_wallet_storage::{LoadCtx, WalletStorageError}; + +/// Blob larger than the 16 MiB cap: one byte over the limit is enough to +/// trigger the pre-read gate without wasting more memory than necessary. +fn oversize_blob() -> Vec { + vec![0u8; platform_wallet_storage::SIZE_LIMIT_BYTES + 1] +} + +fn p2pkh_script() -> Vec { + let mut script = vec![0x76, 0xa9, 0x14]; + script.extend([0x11; 20]); + script.extend([0x88, 0xac]); + script +} + +// ── global SQLITE_LIMIT_LENGTH backstop ───────────────────────────────────── + +/// Every connection opened by this crate via `open_conn` must have +/// `SQLITE_LIMIT_LENGTH` set to `SQLITE_MAX_BLOB_BYTES` (32 MiB). This +/// confirms the global backstop is applied even before any per-column gate. +#[test] +fn connection_has_sqlite_limit_length_set() { + use rusqlite::limits::Limit; + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + // SQLITE_MAX_BLOB_BYTES = 2 × SIZE_LIMIT_BYTES = 32 MiB. + let expected = (platform_wallet_storage::SIZE_LIMIT_BYTES as i64 * 2) as i32; + let actual = conn + .limit(Limit::SQLITE_LIMIT_LENGTH) + .expect("SQLITE_LIMIT_LENGTH must be readable"); + assert_eq!( + actual, expected, + "connection must have SQLITE_LIMIT_LENGTH = {expected} (32 MiB), got {actual}" + ); +} + +// ── core_state::load_state — core_utxos script ────────────────────────────── + +/// An oversize `script` blob in `core_utxos` is caught by the pre-read +/// `length(script)` gate in `core_state::load_state` and returned as +/// `BlobTooLarge` **before** the Vec is allocated. +#[test] +fn blob_gate_core_utxos_load_state_rejects_oversize_script() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xF1); + ensure_wallet_meta(&persister, &w); + + let oversize_script = oversize_blob(); + // A 33-byte outpoint: bincode encodes txid(32 bytes) + vout(1 byte for 0). + // The outpoint gate passes (33 bytes << 16 MiB); only the script gate fires. + let tiny_op = vec![0u8; 33]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![w.as_slice(), tiny_op.as_slice(), oversize_script.as_slice()], + ) + .expect("insert oversize script row"); + + let err = core_state::load_state(&conn, &w, dashcore::Network::Testnet, &LoadCtx::strict()) + .expect_err("load_state must reject an oversize script blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize script, got {err:?}" + ); +} + +// ── core_state::load_state — last_applied_chain_lock ──────────────────────── + +/// An oversize `last_applied_chain_lock` blob is caught by the pre-read +/// `length()` gate in `core_state::load_state` and returned as `BlobTooLarge` +/// **before** the Vec is allocated. +#[test] +fn blob_gate_core_state_load_state_rejects_oversize_chain_lock() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + + let blob = oversize_blob(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_sync_state \ + (wallet_id, last_processed_height, synced_height, last_applied_chain_lock) \ + VALUES (?1, 0, 0, ?2)", + params![w.as_slice(), blob.as_slice()], + ) + .expect("insert oversize chain_lock row"); + + let err = core_state::load_state(&conn, &w, dashcore::Network::Testnet, &LoadCtx::strict()) + .expect_err("load_state must reject an oversize last_applied_chain_lock blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +// ── core_pool::load_used_addresses — core_address_pool script ──────────────── + +/// An oversize `script` blob in `core_address_pool` is caught by the pre-read +/// `length(script)` gate in `core_pool::load_used_addresses` and returned as +/// `BlobTooLarge` **before** the Vec is allocated. +#[test] +fn blob_gate_core_pool_load_used_addresses_rejects_oversize_script() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE1); + ensure_wallet_meta(&persister, &w); + + let oversize_script = oversize_blob(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, 0, ?2, 1)", + params![w.as_slice(), oversize_script.as_slice()], + ) + .expect("insert oversize pool script row"); + + let err = core_pool::load_used_addresses_with_ctx( + &conn, + &w, + dashcore::Network::Testnet, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect_err("load_used_addresses must reject an oversize pool script blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize pool script, got {err:?}" + ); +} + +#[test] +fn blob_gate_core_pool_owning_account_rejects_oversize_user_identity_id() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE3); + ensure_wallet_meta(&persister, &w); + + let script = p2pkh_script(); + let oversize_id = oversize_blob(); + let zero_id = [0u8; 32]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, user_identity_id, \ + friend_identity_id, pool_type, address_index, script, used) \ + VALUES (?1, 'dashpay_receiving', 0, 0, ?2, ?3, 0, 0, ?4, 0)", + params![ + w.as_slice(), + oversize_id.as_slice(), + &zero_id[..], + script.as_slice() + ], + ) + .expect("insert pool row with oversize user identity id"); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![w.as_slice(), &[0x01u8], script.as_slice()], + ) + .expect("insert matching UTXO"); + + let err = core_state::load_used_addresses_with_ctx( + &conn, + &w, + dashcore::Network::Testnet, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect_err("ownership lookup must reject an oversize user identity id"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +#[test] +fn blob_gate_core_pool_load_used_addresses_rejects_oversize_friend_identity_id() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE4); + ensure_wallet_meta(&persister, &w); + + let script = p2pkh_script(); + let oversize_id = oversize_blob(); + let zero_id = [0u8; 32]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, user_identity_id, \ + friend_identity_id, pool_type, address_index, script, used) \ + VALUES (?1, 'dashpay_receiving', 0, 0, ?2, ?3, 0, 0, ?4, 1)", + params![ + w.as_slice(), + &zero_id[..], + oversize_id.as_slice(), + script.as_slice() + ], + ) + .expect("insert used pool row with oversize friend identity id"); + + let err = core_pool::load_used_addresses_with_ctx( + &conn, + &w, + dashcore::Network::Testnet, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect_err("used-address load must reject an oversize friend identity id"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +// ── core_state::load_used_addresses — core_utxos script ────────────────────── + +/// An oversize `script` blob in `core_utxos` is caught by the pre-read +/// `length(script)` gate in `core_state::load_used_addresses` and returned as +/// `BlobTooLarge` **before** the Vec is allocated. +#[test] +fn blob_gate_core_state_load_used_addresses_rejects_oversize_script() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE2); + ensure_wallet_meta(&persister, &w); + + let oversize_script = oversize_blob(); + // 33-byte outpoint (txid 32 + vout 1); its own gate passes, only the + // script gate fires. + let tiny_op = vec![0u8; 33]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![w.as_slice(), tiny_op.as_slice(), oversize_script.as_slice()], + ) + .expect("insert oversize utxo script row"); + + let err = core_state::load_used_addresses_with_ctx( + &conn, + &w, + dashcore::Network::Testnet, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect_err("load_used_addresses must reject an oversize utxo script blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize utxo script, got {err:?}" + ); +} + +// ── platform_addrs — address column (fixed 20 bytes) ──────────────────────── + +/// A `platform_addresses` row whose `address` column is wider than 20 bytes +/// but within the BLOB cap is rejected with `BlobDecode` by the +/// `check_fixed_width` gate before the Vec is materialized. +#[test] +fn blob_gate_platform_addrs_load_all_rejects_wrong_width_address() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xD1); + ensure_wallet_meta(&persister, &w); + + // 21-byte address: wrong width, within size cap. + let bad_addr = vec![0x42u8; 21]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO platform_addresses \ + (wallet_id, account_index, address_index, address, balance, nonce) \ + VALUES (?1, 0, 0, ?2, 0, 0)", + params![w.as_slice(), bad_addr.as_slice()], + ) + .expect("insert wrong-width address row"); + + // load_all drives all_address_rows which has the check_fixed_width gate. + // The rejection is per WALLET: the row is never accepted, and the wallet + // that owns it carries the refusal instead of the whole scan aborting. + use platform_wallet_storage::sqlite::schema::platform_addrs; + let all = platform_addrs::load_all(&conn).expect("the scan itself must survive one bad row"); + let err = all + .get(&w) + .expect("the wallet must be present in the scan") + .as_ref() + .expect_err("load_all must reject a wrong-width address"); + assert!( + matches!( + err, + WalletStorageError::BlobDecode { .. } | WalletStorageError::BlobTooLarge { .. } + ), + "expected BlobDecode or BlobTooLarge for wrong-width address, got {err:?}" + ); +} + +/// A `platform_addresses` row whose `address` column exceeds the 16 MiB cap +/// is rejected with `BlobTooLarge` by the `check_fixed_width` gate. +#[test] +fn blob_gate_platform_addrs_load_all_rejects_oversize_address() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xD2); + ensure_wallet_meta(&persister, &w); + + let oversize_addr = oversize_blob(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO platform_addresses \ + (wallet_id, account_index, address_index, address, balance, nonce) \ + VALUES (?1, 0, 0, ?2, 0, 0)", + params![w.as_slice(), oversize_addr.as_slice()], + ) + .expect("insert oversize address row"); + + use platform_wallet_storage::sqlite::schema::platform_addrs; + let all = platform_addrs::load_all(&conn).expect("the scan itself must survive one bad row"); + let err = all + .get(&w) + .expect("the wallet must be present in the scan") + .as_ref() + .expect_err("load_all must reject an oversize address blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize address, got {err:?}" + ); +} + +// ── identity_keys — bounded inner public_key_bincode decode ────────────────── + +/// A `public_key_blob` that is small enough to pass the outer size gate but +/// contains a crafted `public_key_bincode` whose content causes the inner +/// `blob::bounded_config()` decode to fail deterministically, without +/// OOM-allocating. Proves the inner nested decode is end-to-end capped. +#[test] +fn blob_gate_identity_keys_bounded_inner_public_key_bincode() { + // Build an outer entry blob (tiny, within the 16 MiB gate) that wraps + // a public_key_bincode containing a huge-length varint. The test helper + // in identity_keys builds this without going through the bounded encode. + let crafted_blob = identity_keys::crafted_entry_blob_with_bad_pk_bincode_for_test(); + + assert!( + crafted_blob.len() < platform_wallet_storage::SIZE_LIMIT_BYTES, + "test blob must fit within the outer gate to exercise the inner path" + ); + + // decode_entry: outer blob::decode succeeds (small blob, valid serde wire); + // into_entry's inner decode fails on the crafted pk_bincode. + let err = identity_keys::decode_entry(&crafted_blob) + .expect_err("inner decode must fail on crafted public_key_bincode"); + assert!( + matches!( + err, + WalletStorageError::BincodeDecode { .. } | WalletStorageError::BlobTooLarge { .. } + ), + "expected bounded decode error, got {err:?}" + ); +} + +// ── accounts::load_state — account_xpub_bytes ──────────────────────────────── + +/// An `account_registrations` row whose `account_xpub_bytes` blob exceeds the +/// 16 MiB cap is rejected by `accounts::load_state` with `BlobTooLarge` +/// **before** the Vec is allocated (the `length(account_xpub_bytes)` gate). +#[test] +fn blob_gate_accounts_load_state_rejects_oversize_xpub_bytes() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA1); + ensure_wallet_meta(&persister, &w); + + let blob = oversize_blob(); + let conn = persister.lock_conn_for_test(); + // Plant the oversize blob directly; `zero_id` (32-byte all-zero) is the + // default sentinel for `user_identity_id` / `friend_identity_id`. + let zero_id = [0u8; 32]; + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id, account_xpub_bytes) \ + VALUES (?1, 'platform_payment', 0, 0, ?2, ?3, ?4)", + params![w.as_slice(), &zero_id[..], &zero_id[..], blob.as_slice()], + ) + .expect("insert oversize xpub_bytes row"); + + let err = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect_err("load_state must reject an oversize account_xpub_bytes blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +#[test] +fn blob_gate_accounts_bulk_platform_payment_rejects_oversize_wallet_id() { + let (persister, _tmp, _path) = fresh_persister(); + let oversize_id = oversize_blob(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![oversize_id.as_slice()], + ) + .expect("insert wallet with oversize id"); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'platform_payment', 0, ?2)", + params![oversize_id.as_slice(), &[0x00u8]], + ) + .expect("insert platform payment row with oversize wallet id"); + + let err = platform_wallet_storage::sqlite::schema::platform_addrs::load_all(&conn) + .expect_err("bulk account load must reject an oversize wallet id"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +#[test] +fn blob_gate_accounts_ecdsa_reader_rejects_oversize_user_identity_id() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA2); + ensure_wallet_meta(&persister, &w); + + let oversize_id = oversize_blob(); + let zero_id = [0u8; 32]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, user_identity_id, \ + friend_identity_id, account_xpub_bytes) \ + VALUES (?1, 'dashpay_receiving', 0, ?2, ?3, ?4)", + params![ + w.as_slice(), + oversize_id.as_slice(), + &zero_id[..], + &[0x00u8] + ], + ) + .expect("insert account row with oversize user identity id"); + + let err = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect_err("ECDSA account load must reject an oversize user identity id"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +#[test] +fn blob_gate_accounts_provider_reader_rejects_oversize_friend_identity_id() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA3); + ensure_wallet_meta(&persister, &w); + + let oversize_id = oversize_blob(); + let zero_id = [0u8; 32]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, user_identity_id, \ + friend_identity_id, account_xpub_bytes) \ + VALUES (?1, 'provider_operator', 0, ?2, ?3, ?4)", + params![ + w.as_slice(), + &zero_id[..], + oversize_id.as_slice(), + &[0x00u8] + ], + ) + .expect("insert provider row with oversize friend identity id"); + + let err = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect_err("provider account load must reject an oversize friend identity id"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +// ── identity_keys::load_state — public_key_blob ────────────────────────────── + +/// An `identity_keys` row whose `public_key_blob` exceeds the 16 MiB cap is +/// rejected by `identity_keys::load_state` with `BlobTooLarge` before the Vec +/// is materialised (the `length(public_key_blob)` gate). +#[test] +fn blob_gate_identity_keys_load_state_rejects_oversize_public_key_blob() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xB1); + ensure_wallet_meta(&persister, &w); + let identity_id = [0xCCu8; 32]; + + let blob = oversize_blob(); + let conn = persister.lock_conn_for_test(); + // `identity_keys` has a FK to `identities(identity_id)`; plant the stub. + identities::ensure_exists(&conn, &w, &identity_id).expect("ensure identity stub"); + let zero_hash = [0u8; 20]; + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, ?3, ?4, NULL)", + params![ + w.as_slice(), + &identity_id[..], + blob.as_slice(), + &zero_hash[..] + ], + ) + .expect("insert oversize public_key_blob row"); + + let err = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect_err("load_state must reject an oversize public_key_blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs index 129e76bfdf7..68eadff1d13 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs @@ -1,8 +1,7 @@ -//! Smoke tests for the enum-domain `CHECK` constraints on the five -//! enum-shaped TEXT columns (`wallet_metadata.network`, -//! `account_registrations.account_type`, -//! `account_address_pools.account_type`/`pool_type`, -//! `core_derived_addresses.account_type`, and `asset_locks.status`). +//! Smoke tests for the enum-domain `CHECK` constraints. The schema has +//! four such TEXT columns across four domains: `wallets.network`, +//! `account_registrations.account_type`, `asset_locks.status`, and the +//! synthetic `contacts.state`. These tests exercise each directly. //! //! The per-module parity unit tests in `src/sqlite/schema/*` cover the //! Rust↔const-array equality. These tests cover the runtime half: a @@ -43,10 +42,10 @@ fn check_rejects_bad_network_label() { let (persister, _tmp, _path) = fresh_persister(); let conn = persister.lock_conn_for_test(); let res = conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", params![wid(1).as_slice(), "not-a-network", 0i64], ); - assert_constraint_check(res, "wallet_metadata.network"); + assert_constraint_check(res, "wallets.network"); } #[test] @@ -55,10 +54,10 @@ fn check_rejects_bad_account_type_on_registrations() { let conn = persister.lock_conn_for_test(); // First seed a valid parent row so we don't trip the FK. conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", params![wid(2).as_slice(), "testnet", 0i64], ) - .expect("seed wallet_metadata"); + .expect("seed wallets"); let res = conn.execute( "INSERT INTO account_registrations \ (wallet_id, account_type, account_index, account_xpub_bytes) \ @@ -68,39 +67,15 @@ fn check_rejects_bad_account_type_on_registrations() { assert_constraint_check(res, "account_registrations.account_type"); } -#[test] -fn check_rejects_bad_pool_type() { - 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, ?2, ?3)", - params![wid(3).as_slice(), "testnet", 0i64], - ) - .expect("seed wallet_metadata"); - let res = conn.execute( - "INSERT INTO account_address_pools \ - (wallet_id, account_type, account_index, pool_type, snapshot_blob) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - wid(3).as_slice(), - "standard", - 0i64, - "not_a_pool", - &[0u8; 4][..] - ], - ); - assert_constraint_check(res, "account_address_pools.pool_type"); -} - #[test] fn check_rejects_bad_asset_lock_status() { 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, ?2, ?3)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", params![wid(4).as_slice(), "testnet", 0i64], ) - .expect("seed wallet_metadata"); + .expect("seed wallets"); let res = conn.execute( "INSERT INTO asset_locks \ (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ @@ -128,9 +103,64 @@ fn check_accepts_every_known_label_network() { { let wid_bytes = [i as u8 + 10; 32]; conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", params![wid_bytes.as_slice(), *label, 0i64], ) .unwrap_or_else(|e| panic!("network={label} should be accepted: {e}")); } } + +#[test] +fn check_rejects_bad_contact_state() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + // Seed a valid parent wallet so the insert trips the state CHECK, not the FK. + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + params![wid(7).as_slice(), "testnet", 0i64], + ) + .expect("seed wallets"); + let res = conn.execute( + "INSERT INTO contacts (wallet_id, owner_id, contact_id, state) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + wid(7).as_slice(), + &[0xAAu8; 32][..], + &[0xBBu8; 32][..], + "not_a_contact_state" + ], + ); + assert_constraint_check(res, "contacts.state"); +} + +#[test] +fn check_accepts_every_known_contact_state_label() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + params![wid(8).as_slice(), "testnet", 0i64], + ) + .expect("seed wallets"); + // Mirrors `sqlite::schema::contacts::CONTACT_STATE_LABELS`; hardcoded + // because that const is `pub(crate)` and unreachable from this separate + // integration-test crate (same constraint as the network test above). + // The per-module `contact_state_labels_match_enum` unit test guards the + // const itself against drift, so a label added there without updating + // this list surfaces in that test, not as a silent gap here. + for (i, label) in ["sent", "received", "established"].iter().enumerate() { + // Same wallet+owner, distinct contact_id per label to keep the + // composite PK (wallet_id, owner_id, contact_id) unique. + conn.execute( + "INSERT INTO contacts (wallet_id, owner_id, contact_id, state) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + wid(8).as_slice(), + &[0xC0u8; 32][..], + &[i as u8; 32][..], + *label + ], + ) + .unwrap_or_else(|e| panic!("contact state={label} should be accepted: {e}")); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs b/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs index e97bb648565..8d6811e4e83 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs @@ -2,6 +2,8 @@ //! CLI smoke tests for the maintenance binary. +mod common; + use std::process::Command; use assert_cmd::cargo::CommandCargoExt; @@ -10,10 +12,128 @@ fn cli() -> Command { Command::cargo_bin("platform-wallet-storage").expect("bin built") } +#[test] +fn missing_db_is_usage_error_for_database_commands() { + for args in [ + vec!["migrate"], + vec!["backup", "--out", "unused.db"], + vec!["restore", "--from", "unused.db", "--yes"], + ] { + let out = cli().args(&args).output().unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "{args:?} without --db must exit 2; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + } +} + +#[test] +fn restore_source_validation_errors_exit_three() { + let tmp = common::secure_tempdir().unwrap(); + let valid = tmp.path().join("valid.db"); + let migrated = cli() + .args(["--db", valid.to_str().unwrap(), "migrate"]) + .output() + .unwrap(); + assert!( + migrated.status.success(), + "source migrate failed: {migrated:?}" + ); + + let foreign = tmp.path().join("foreign.db"); + let conn = rusqlite::Connection::open(&foreign).unwrap(); + conn.execute_batch( + "CREATE TABLE refinery_schema_history ( + version INTEGER PRIMARY KEY, + name TEXT, + applied_on TEXT, + checksum TEXT + ); + INSERT INTO refinery_schema_history (version, name, applied_on, checksum) + VALUES (1, 'initial', '2026-01-01T00:00:00+00:00', '12345');", + ) + .unwrap(); + drop(conn); + + let future = tmp.path().join("future.db"); + std::fs::copy(&valid, &future).unwrap(); + let conn = rusqlite::Connection::open(&future).unwrap(); + conn.execute( + "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ + VALUES (1000000, 'future', '2026-01-01T00:00:00+00:00', '0')", + [], + ) + .unwrap(); + drop(conn); + + let malformed = tmp.path().join("malformed.db"); + std::fs::copy(&valid, &malformed).unwrap(); + let conn = rusqlite::Connection::open(&malformed).unwrap(); + conn.execute( + "UPDATE refinery_schema_history SET applied_on = 'not-a-timestamp' WHERE version = 1", + [], + ) + .unwrap(); + drop(conn); + + let corrupt = tmp.path().join("corrupt.db"); + std::fs::write(&corrupt, b"not a sqlite database").unwrap(); + + let missing = tmp.path().join("missing.db"); + let dest = tmp.path().join("dest.db"); + for source in [&missing, &foreign, &future, &malformed, &corrupt] { + let out = cli() + .args([ + "--db", + dest.to_str().unwrap(), + "restore", + "--from", + source.to_str().unwrap(), + "--yes", + "--no-auto-backup", + ]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(3), + "source {} must be a validation error; stderr={}", + source.display(), + String::from_utf8_lossy(&out.stderr) + ); + } +} + +#[test] +fn restore_missing_schema_history_message_is_not_integrity_failure() { + let tmp = common::secure_tempdir().unwrap(); + let source = tmp.path().join("empty.db"); + rusqlite::Connection::open(&source).unwrap(); + let dest = tmp.path().join("dest.db"); + let out = cli() + .args([ + "--db", + dest.to_str().unwrap(), + "restore", + "--from", + source.to_str().unwrap(), + "--yes", + "--no-auto-backup", + ]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(3)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("schema history missing"), "stderr={stderr}"); + assert!(!stderr.contains("integrity check"), "stderr={stderr}"); +} + /// migrate on a fresh DB prints `applied: ` then `applied: 0`. #[test] fn tc056_migrate_idempotent() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let db = tmp.path().join("w.db"); let out = cli() .args(["--db", db.to_str().unwrap(), "migrate"]) @@ -37,7 +157,7 @@ fn tc056_migrate_idempotent() { /// restore without --yes refuses (exit 2). #[test] fn tc062_restore_without_yes_refuses() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let db = tmp.path().join("w.db"); cli() .args(["--db", db.to_str().unwrap(), "migrate"]) @@ -67,7 +187,7 @@ fn tc062_restore_without_yes_refuses() { /// prune without --keep-last or --max-age is a usage error. #[test] fn tc065_prune_requires_a_rule() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let db = tmp.path().join("w.db"); let dir = tmp.path().join("bk"); std::fs::create_dir(&dir).unwrap(); @@ -88,7 +208,7 @@ fn tc065_prune_requires_a_rule() { /// unknown-subcommand usage error. #[test] fn inspect_subcommand_removed() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let db = tmp.path().join("w.db"); cli() .args(["--db", db.to_str().unwrap(), "migrate"]) @@ -110,7 +230,7 @@ fn inspect_subcommand_removed() { /// an unknown-subcommand usage error. #[test] fn tc072_delete_wallet_subcommand_removed() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let db = tmp.path().join("w.db"); cli() .args(["--db", db.to_str().unwrap(), "migrate"]) @@ -142,7 +262,7 @@ fn tc072_delete_wallet_subcommand_removed() { /// backup --out writes a timestamped file. #[test] fn tc059_backup_dir() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let db = tmp.path().join("w.db"); cli() .args(["--db", db.to_str().unwrap(), "migrate"]) @@ -172,7 +292,7 @@ fn tc059_backup_dir() { /// 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 tmp = common::secure_tempdir().unwrap(); let db = tmp.path().join("w.db"); let out = cli() .args(["--db", db.to_str().unwrap(), "migrate", "--no-auto-backup"]) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rs b/packages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rs new file mode 100644 index 00000000000..89c8d8960fc --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rs @@ -0,0 +1,198 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `commit_writes` LockPoisoned short-circuit accounting: a +//! `PersistenceError::LockPoisoned` from any wallet's flush aborts the +//! loop early — the offending wallet lands in `failed` and every +//! not-yet-attempted wallet is moved to `still_pending`. +//! +//! The report-accounting test uses the deterministic +//! `force_next_flush_to_fail` injector. The permanence test poisons the real +//! connection mutex from a panicking thread. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister_with_mode, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::{FlushMode, WalletStorageError}; + +fn changeset(synced: u32) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(synced), + last_processed_height: Some(synced), + ..Default::default() + }), + ..Default::default() + } +} + +/// Wallets flush in sorted-id order. Priming a `LockPoisoned` to fire on +/// the FIRST flush (wallet A) must: +/// - record A in `failed` (as LockPoisoned), +/// - move the not-yet-attempted wallets B and C into `still_pending`, +/// - leave `succeeded` empty, +/// - and `commit_writes` itself still returns `Ok(report)` (the loop +/// short-circuits cleanly, it does not propagate `Err`). +#[test] +fn lock_poisoned_short_circuit_fills_still_pending() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let a = wid(0xA0); + let b = wid(0xB0); + let c = wid(0xC0); + for id in [&a, &b, &c] { + ensure_wallet_meta(&persister, id); + } + persister.store(a, changeset(1)).unwrap(); + persister.store(b, changeset(2)).unwrap(); + persister.store(c, changeset(3)).unwrap(); + + // Fires on the first flush_inner -> sorted order -> wallet A. + persister.force_next_flush_to_fail(WalletStorageError::LockPoisoned); + + let report = persister + .commit_writes() + .expect("commit_writes must return Ok(report), not Err, on a LockPoisoned short-circuit"); + + assert_eq!( + report.failed.len(), + 1, + "exactly one wallet (A) must be recorded as failed; report={report:?}" + ); + assert_eq!(report.failed[0].0, a, "the failed wallet must be A"); + assert!( + matches!( + report.failed[0].1, + platform_wallet::changeset::PersistenceError::LockPoisoned + ), + "A's failure must be LockPoisoned, got {:?}", + report.failed[0].1 + ); + + assert!( + report.succeeded.is_empty(), + "no wallet should have flushed after the short-circuit; report={report:?}" + ); + + let mut pending = report.still_pending.clone(); + pending.sort(); + assert_eq!( + pending, + vec![b, c], + "B and C were never attempted and must land in still_pending; report={report:?}" + ); + assert!( + !report.is_ok(), + "a report with failures must not be is_ok()" + ); + assert!( + !persister.buffer_has_changeset_for_test(&a), + "the failed wallet's fatal changeset must be discarded" + ); + assert!(persister.buffer_has_changeset_for_test(&b)); + assert!(persister.buffer_has_changeset_for_test(&c)); + + // B and C must NOT be durable — the loop never reached them. + let conn = common::ro_conn(&path); + for id in [&b, &c] { + let n: 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!( + n, + 0, + "still_pending wallet {} must not have been flushed", + hex::encode(id) + ); + } +} + +#[test] +fn real_connection_mutex_poison_is_permanent_and_drops_failed_changeset() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let persister = std::sync::Arc::new(persister); + let wallet_id = wid(0xD0); + let other_wallet_id = wid(0xD1); + ensure_wallet_meta(&persister, &wallet_id); + ensure_wallet_meta(&persister, &other_wallet_id); + persister.store(wallet_id, changeset(42)).unwrap(); + persister.store(other_wallet_id, changeset(43)).unwrap(); + assert!(persister.buffer_has_changeset_for_test(&wallet_id)); + assert!(persister.buffer_has_changeset_for_test(&other_wallet_id)); + + let poisoner = std::sync::Arc::clone(&persister); + let panic_result = std::thread::spawn(move || { + let _connection = poisoner.lock_conn_for_test(); + panic!("poison the SQLite connection mutex"); + }) + .join(); + assert!(panic_result.is_err(), "poisoning thread must panic"); + + let flush_err = persister + .flush(wallet_id) + .expect_err("flush must surface the poisoned connection"); + assert!(matches!( + flush_err, + platform_wallet::changeset::PersistenceError::LockPoisoned + )); + assert!( + !persister.buffer_has_changeset_for_test(&wallet_id), + "the changeset drained by the fatal flush must be discarded" + ); + assert!( + !persister.buffer_has_changeset_for_test(&other_wallet_id), + "connection poison must discard every wallet's buffered changeset" + ); + + assert!(matches!( + persister.store(wallet_id, changeset(44)), + Err(platform_wallet::changeset::PersistenceError::LockPoisoned) + )); + assert!(matches!( + persister.flush(wallet_id), + Err(platform_wallet::changeset::PersistenceError::LockPoisoned) + )); + assert!(matches!( + persister.commit_writes(), + Err(platform_wallet::changeset::PersistenceError::LockPoisoned) + )); + + for attempt in 1..=3 { + let load_err = persister + .load() + .expect_err("load must keep surfacing the poisoned connection"); + assert!( + matches!( + load_err, + platform_wallet::changeset::PersistenceError::LockPoisoned + ), + "load attempt {attempt} returned {load_err:?}" + ); + } + assert!(matches!( + persister.delete_wallet(wallet_id), + Err(WalletStorageError::LockPoisoned) + )); + + drop(persister); + let reopened = platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(&path) + .with_flush_mode(FlushMode::Manual), + ) + .expect("dropping the poisoned instance must release the same-path guard"); + assert!(!reopened.buffer_has_changeset_for_test(&wallet_id)); + let conn = reopened.lock_conn_for_test(); + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(rows, 0, "the discarded changeset must not replay on reopen"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 12c5cb863f5..fb25a7af8c5 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -4,6 +4,8 @@ //! TC-P1-003 — every writer call site uses `prepare_cached`. //! TC-P4-011 — `ClientStartState` keeps the base public shape. +mod common; + use std::sync::Arc; use platform_wallet::changeset::PlatformWalletPersistence; @@ -16,7 +18,7 @@ assert_impl_all!(SqlitePersister: Send, Sync, PlatformWalletPersistence); #[test] fn tc078_object_safety() { fn accepts(_: Arc) {} - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let path = tmp.path().join("w.db"); let cfg = SqlitePersisterConfig::new(&path); let p = SqlitePersister::open(cfg).unwrap(); @@ -30,14 +32,19 @@ fn tc078_object_safety() { /// rarely do. const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ( - "wallet_meta.rs", - "SELECT wallet_id FROM wallet_metadata ORDER BY wallet_id", + "wallets.rs", + "SELECT wallet_id FROM wallets ORDER BY wallet_id", + ), + ( + "wallets.rs", + "SELECT network, birth_height FROM wallets WHERE wallet_id", ), + // asset_locks readers (load_active / load_unconsumed / list_active) — + // pre-read length() gates on outpoint and lifecycle_blob. ( - "wallet_meta.rs", - "SELECT network, birth_height FROM wallet_metadata WHERE wallet_id", + "asset_locks.rs", + "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status", ), - ("asset_locks.rs", "SELECT outpoint, account_index"), ("platform_addrs.rs", "SELECT account_index, address_index"), // Grouped bulk readers driving `load()` — fixed scans over the whole // table, not per-wallet fan-out. @@ -47,21 +54,68 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ), ( "platform_addrs.rs", - "SELECT wallet_id, account_index, address_index, address, balance, nonce", + "SELECT wallet_id, account_index, address_index, length(address), address, balance, nonce", ), + // Pre-read `length()` gates added by PR #3968 review — substrings updated + // to reflect the new `length()` column in each SELECT. ( "accounts.rs", - "SELECT account_index, account_xpub_bytes FROM account_registrations", + "SELECT account_index, key_class, length(account_xpub_bytes), account_xpub_bytes", ), ( "accounts.rs", - "SELECT wallet_id, account_index, account_xpub_bytes FROM account_registrations", + "SELECT length(wallet_id), wallet_id, account_index, key_class,", + ), + // load_state unspent-UTXO reader: pre-read length() gates on outpoint and script. + ( + "core_state.rs", + "SELECT length(outpoint), outpoint, value, length(script), script", + ), + ("core_state.rs", "SELECT DISTINCT script FROM core_utxos"), + // Pool reader: verbatim used-set with owner columns, a one-shot read-only + // scan per wallet. + ( + "core_pool.rs", + "SELECT script, account_type, account_index,", + ), + // Typed-pool reader: one-shot read-only scan of pre-derived platform-node + // keys per wallet, during `load()` rehydration. + ( + "core_pool.rs", + "SELECT address_index, length(script), script, length(public_key), public_key", + ), + // Full-rehydration readers — one-shot SELECTs in `load_state`. + ( + "accounts.rs", + "SELECT account_type, account_index, key_class,", + ), + ( + "core_state.rs", + "SELECT length(txid), txid, height, length(record_blob), record_blob", + ), + ( + "core_state.rs", + "SELECT length(txid), txid, length(islock_blob), islock_blob", + ), + ( + "core_state.rs", + "SELECT last_processed_height, synced_height,", + ), + ( + "identity_keys.rs", + "SELECT identity_id, key_id, length(public_key_blob), public_key_blob", ), - ("core_state.rs", "SELECT outpoint, value, script, height"), // P4 readers — `load_state` per area uses one-shot SELECTs. + // Substring covers both `fetch` (`SELECT length(entry_blob)…`) and + // `load_state` (`SELECT identity_id, length(entry_blob)…`). + ( + "identities.rs", + "length(entry_blob), entry_blob, tombstoned", + ), + // load_tombstoned_ids supplies the merge's positive tombstone signal. ( "identities.rs", - "SELECT identity_id, entry_blob, tombstoned", + "SELECT identity_id FROM identities WHERE", ), ("contacts.rs", "SELECT owner_id, contact_id, state"), ( @@ -89,6 +143,7 @@ fn tc_p1_003_prepare_cached_in_writers() { .join("sqlite") .join("schema"); let mut offenders: Vec<(String, usize, String)> = Vec::new(); + let mut allowlist_hits = vec![0usize; READ_ONLY_PREPARE_ALLOWED.len()]; for entry in std::fs::read_dir(&schema_dir).expect("read schema dir") { let entry = entry.expect("schema dir entry"); let path = entry.path(); @@ -122,8 +177,10 @@ fn tc_p1_003_prepare_cached_in_writers() { .join("\n"); let allowed = READ_ONLY_PREPARE_ALLOWED .iter() - .any(|(f, sql)| *f == file_name && probe.contains(sql)); - if allowed { + .enumerate() + .find(|(_, (f, sql))| *f == file_name && probe.contains(sql)); + if let Some((allowlist_index, _)) = allowed { + allowlist_hits[allowlist_index] += 1; continue; } offenders.push((file_name.to_string(), idx + 1, (*line).to_string())); @@ -134,6 +191,15 @@ fn tc_p1_003_prepare_cached_in_writers() { "writer paths must use `prepare_cached`; offenders: {:#?}", offenders ); + let stale_allowlist_entries: Vec<_> = READ_ONLY_PREPARE_ALLOWED + .iter() + .zip(allowlist_hits) + .filter_map(|(entry, hits)| (hits == 0).then_some(entry)) + .collect(); + assert!( + stale_allowlist_entries.is_empty(), + "read-only prepare allowlist entries must match a call site: {stale_allowlist_entries:#?}" + ); } /// TC-P4-011: `ClientStartState` keeps the base public shape — plain diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs new file mode 100644 index 00000000000..4441f4027d1 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs @@ -0,0 +1,701 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Pre-keyed rehydration: identities load from SQLite already carrying +//! their own public keys + contact state, with nothing layered on +//! afterwards. store → drop → reopen → `load()` → assert on the +//! `ManagedIdentity` fields directly. + +mod common; + +use std::collections::{BTreeMap, HashSet}; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +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::{ + ContactChangeSet, ContactRequestEntry, IdentityChangeSet, IdentityEntry, IdentityKeyEntry, + IdentityKeysChangeSet, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + ReceivedContactRequestKey, SentContactRequestKey, +}; +use platform_wallet::wallet::identity::{ContactRequest, EstablishedContact, IdentityStatus}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::WalletStorageError; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +/// A wallet-owned identity entry — lands in `wallet_identities[w][index]`. +fn wallet_identity_entry(id: Identifier, w: WalletId, index: u32) -> IdentityEntry { + IdentityEntry { + id, + balance: 1_000, + revision: 1, + identity_index: Some(index), + 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: Some(w), + dashpay_profile: None, + dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } +} + +/// An out-of-wallet identity entry (`identity_index = None`) — lands in +/// `out_of_wallet_identities`. +fn out_of_wallet_entry(id: Identifier, w: WalletId) -> IdentityEntry { + IdentityEntry { + identity_index: None, + ..wallet_identity_entry(id, w, 0) + } +} + +fn id_changeset(entries: impl IntoIterator) -> IdentityChangeSet { + let mut cs = IdentityChangeSet::default(); + for e in entries { + cs.identities.insert(e.id, e); + } + cs +} + +fn key_entry(id: Identifier, key_id: u32, byte: u8, security: SecurityLevel) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: id, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: security, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + } +} + +fn keys_changeset(entries: impl IntoIterator) -> IdentityKeysChangeSet { + let mut cs = IdentityKeysChangeSet::default(); + for e in entries { + cs.upserts.insert((e.identity_id, e.key_id), e); + } + cs +} + +fn contact_request(sender: Identifier, recipient: Identifier) -> ContactRequest { + ContactRequest { + sender_id: sender, + recipient_id: recipient, + sender_key_index: 1, + recipient_key_index: 2, + account_reference: 3, + encrypted_account_label: None, + encrypted_public_key: vec![9, 9, 9], + auto_accept_proof: None, + core_height_created_at: 42, + created_at: 7, + } +} + +fn established(owner: Identifier, contact: Identifier) -> EstablishedContact { + EstablishedContact { + contact_identity_id: contact, + outgoing_request: contact_request(owner, contact), + incoming_request: contact_request(contact, owner), + alias: Some("friend".into()), + note: Some("met at conf".into()), + is_hidden: false, + accepted_accounts: vec![0, 3, 7], + payment_channel_broken: false, + contact_account_label: None, + external_account_reference: None, + } +} + +/// A freshly-loaded identity carries its persisted keys in +/// `public_keys()` immediately, bit-exact, with no sync. +#[test] +fn tc1_identity_keys_populate_public_keys_on_load() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x44; 32]); + let e0 = key_entry(id, 0, 0xAA, SecurityLevel::HIGH); + let e1 = key_entry(id, 1, 0xBB, SecurityLevel::HIGH); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(id, w, 0)])), + identity_keys: Some(keys_changeset([e0.clone(), e1.clone()])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + let pks = managed.identity.public_keys(); + assert_eq!(pks.len(), 2); + assert_eq!(pks.get(&0), Some(&e0.public_key)); + assert_eq!(pks.get(&1), Some(&e1.public_key)); +} + +/// The freshly-loaded AUTHENTICATION/CRITICAL key is +/// immediately selectable via the exact `get_first_public_key_matching` +/// predicate the Platform signing path uses, before any sync. +#[test] +fn tc2_loaded_auth_key_is_selectable_for_signing() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x55; 32]); + let key = key_entry(id, 0, 0xCC, SecurityLevel::CRITICAL); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(id, w, 0)])), + identity_keys: Some(keys_changeset([key.clone()])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + // Exact call shape from wallet/identity/network/dpns.rs:207 and friends. + let selected = managed + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::HIGH, SecurityLevel::CRITICAL]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .expect("auth signing key selectable immediately after load, no sync"); + assert_eq!(selected.id(), 0); + assert_eq!(selected, &key.public_key); +} + +/// An established contact restores onto the identity, bit-exact. +#[test] +fn tc3_established_contact_restores_onto_identity() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC3); + ensure_wallet_meta(&persister, &w); + let owner = Identifier::from([0x11; 32]); + let contact = Identifier::from([0x22; 32]); + let contact_state = established(owner, contact); + let mut established_map = BTreeMap::new(); + established_map.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: contact, + }, + contact_state.clone(), + ); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(owner, w, 0)])), + contacts: Some(ContactChangeSet { + established: established_map, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + assert_eq!(managed.dashpay().established_contacts().len(), 1); + assert_eq!( + managed.dashpay().established_contacts().get(&contact), + Some(&contact_state) + ); + assert!(managed.dashpay().sent_contact_requests().is_empty()); + assert!(managed.dashpay().incoming_contact_requests().is_empty()); +} + +/// Pending sent and incoming requests restore with correct +/// directionality and map keys. +#[test] +fn tc4_sent_and_incoming_requests_restore_directionally() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC4); + ensure_wallet_meta(&persister, &w); + let owner = Identifier::from([0x11; 32]); + let recipient = Identifier::from([0x22; 32]); + let sender = Identifier::from([0x33; 32]); + + let mut sent = BTreeMap::new(); + sent.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: recipient, + }, + ContactRequestEntry { + request: contact_request(owner, recipient), + }, + ); + let mut incoming = BTreeMap::new(); + incoming.insert( + ReceivedContactRequestKey { + owner_id: owner, + sender_id: sender, + }, + ContactRequestEntry { + request: contact_request(sender, owner), + }, + ); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(owner, w, 0)])), + contacts: Some(ContactChangeSet { + sent_requests: sent, + incoming_requests: incoming, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + let s = managed + .dashpay() + .sent_contact_requests() + .get(&recipient) + .expect("sent restored, keyed by recipient"); + assert_eq!(s.sender_id, owner); + assert_eq!(s.recipient_id, recipient); + let i = managed + .dashpay() + .incoming_contact_requests() + .get(&sender) + .expect("incoming restored, keyed by sender"); + assert_eq!(i.sender_id, sender); + assert_eq!(i.recipient_id, owner); + assert!(managed.dashpay().established_contacts().is_empty()); +} + +/// Two identities in one wallet with the same numeric `KeyID` keep +/// disjoint key maps; the group-by must not misattribute. +#[test] +fn tc5_no_cross_identity_key_leakage() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC5); + ensure_wallet_meta(&persister, &w); + let a = Identifier::from([0xA0; 32]); + let b = Identifier::from([0xB0; 32]); + let a0 = key_entry(a, 0, 0x0A, SecurityLevel::HIGH); + let a1 = key_entry(a, 1, 0x1A, SecurityLevel::HIGH); + let b0 = key_entry(b, 0, 0x0B, SecurityLevel::HIGH); // same KeyID 0, different data + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([ + wallet_identity_entry(a, w, 0), + wallet_identity_entry(b, w, 1), + ])), + identity_keys: Some(keys_changeset([a0.clone(), a1.clone(), b0.clone()])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let wallet = &state.wallets[&w].identity_manager.wallet_identities[&w]; + let managed_a = &wallet[&0]; + let managed_b = &wallet[&1]; + assert_eq!(managed_a.identity.public_keys().len(), 2); + assert_eq!(managed_b.identity.public_keys().len(), 1); + assert_eq!( + managed_a.identity.public_keys().get(&0), + Some(&a0.public_key) + ); + assert_eq!( + managed_b.identity.public_keys().get(&0), + Some(&b0.public_key) + ); + assert_ne!( + managed_a.identity.public_keys().get(&0), + managed_b.identity.public_keys().get(&0), + "same KeyID on different identities must not collapse" + ); +} + +/// Contact state does not leak across identities in one wallet. +#[test] +fn tc6_no_cross_identity_contact_leakage() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC6); + ensure_wallet_meta(&persister, &w); + let a = Identifier::from([0xA0; 32]); + let b = Identifier::from([0xB0; 32]); + let c = Identifier::from([0xCC; 32]); + let d = Identifier::from([0xDD; 32]); + + let mut established_map = BTreeMap::new(); + established_map.insert( + SentContactRequestKey { + owner_id: a, + recipient_id: c, + }, + established(a, c), + ); + let mut sent = BTreeMap::new(); + sent.insert( + SentContactRequestKey { + owner_id: b, + recipient_id: d, + }, + ContactRequestEntry { + request: contact_request(b, d), + }, + ); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([ + wallet_identity_entry(a, w, 0), + wallet_identity_entry(b, w, 1), + ])), + contacts: Some(ContactChangeSet { + established: established_map, + sent_requests: sent, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let wallet = &state.wallets[&w].identity_manager.wallet_identities[&w]; + let managed_a = &wallet[&0]; + let managed_b = &wallet[&1]; + assert!(managed_a.dashpay().established_contacts().contains_key(&c)); + assert!(managed_a.dashpay().sent_contact_requests().is_empty()); + assert!(managed_b.dashpay().sent_contact_requests().contains_key(&d)); + assert!(managed_b.dashpay().established_contacts().is_empty()); +} + +/// An identity with zero persisted keys loads fine with an empty +/// key map and its scalar fields intact. +#[test] +fn tc7_identity_with_zero_keys_loads_empty() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC7); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x77; 32]); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(id, w, 0)])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load succeeds"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + assert!(managed.identity.public_keys().is_empty()); + assert_eq!(managed.identity.balance(), 1_000); + assert_eq!(managed.identity.revision(), 1); + assert!(managed.dashpay().established_contacts().is_empty()); +} + +/// An out-of-wallet identity (no `identity_index`) with zero keys +/// and contacts loads into `out_of_wallet_identities`, empty. +/// +/// Note: the SQLite `load_state`/`managed_identity_from_entry` path always +/// fills `wallet_id` with the load scope, so a row read under wallet `w` +/// carries `wallet_id = Some(w)` even in the out-of-wallet bucket (the +/// spec's `wallet_id: None` is unreachable through the wallet-scoped +/// reader). We assert the bucket placement and emptiness — the join's +/// concern — not that field. +#[test] +fn tc8_out_of_wallet_identity_loads_empty() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC8); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x88; 32]); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([out_of_wallet_entry(id, w)])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load succeeds"); + let im = &state.wallets[&w].identity_manager; + let managed = im + .out_of_wallet_identities + .get(&id) + .expect("out-of-wallet identity present"); + assert!(managed.identity.public_keys().is_empty()); + assert!(managed.dashpay().established_contacts().is_empty()); + assert!(managed.dashpay().sent_contact_requests().is_empty()); + assert!(managed.dashpay().incoming_contact_requests().is_empty()); +} + +/// A tombstoned identity's orphaned key/contact rows are the one +/// orphan class recovery mode forgives. Strict refuses them: "the owner is +/// gone" is exactly the state that silently discards live key material, so +/// only an operator who asked for a best-effort load gets the old skip. +#[test] +fn tc9_tombstoned_identity_orphan_rows_load_only_in_recovery() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC9); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x99; 32]); + let contact = Identifier::from([0xAB; 32]); + let mut established_map = BTreeMap::new(); + established_map.insert( + SentContactRequestKey { + owner_id: id, + recipient_id: contact, + }, + established(id, contact), + ); + // Store identity + key + contact. + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(id, w, 0)])), + identity_keys: Some(keys_changeset([key_entry( + id, + 0, + 0x99, + SecurityLevel::HIGH, + )])), + contacts: Some(ContactChangeSet { + established: established_map, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + // Tombstone the identity only; its key/contact rows are left behind. + let mut removed = IdentityChangeSet::default(); + removed.removed.insert(id); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(removed), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let strict = reopen(&path) + .load() + .expect_err("orphan rows of a tombstoned owner must abort a strict load"); + let PersistenceError::Backend { source, .. } = strict else { + panic!("expected a typed backend error, got {strict:?}"); + }; + assert!( + matches!( + source.downcast_ref::(), + Some(WalletStorageError::OrphanedIdentityEntry { .. }) + ), + "expected OrphanedIdentityEntry, got {source:?}" + ); + + let recovery = platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(&path) + .with_load_policy(platform_wallet_storage::LoadPolicy::Recovery), + ) + .expect("reopen in recovery mode"); + let state = recovery + .load() + .expect("recovery must complete the load despite orphan rows"); + assert_eq!( + recovery + .last_load_degradation() + .by_site + .get(&platform_wallet_storage::LoadSite::TombstonedIdentityOrphan) + .copied(), + // One per leftover row: the key row and the established-contact row. + Some(2), + "both leftover collections must be counted" + ); + let im = &state.wallets[&w].identity_manager; + let present = im + .wallet_identities + .values() + .flat_map(|m| m.values()) + .any(|m| m.identity.id() == id) + || im.out_of_wallet_identities.contains_key(&id); + assert!( + !present, + "tombstoned identity must be absent from both buckets" + ); +} + +/// Several leftover rows of one tombstoned owner in one collection must +/// count several times: every other `LoadSite` counts occurrences, so a +/// rescue operator reading `by_site` would otherwise be unable to tell +/// three lost keys from three lost collections. +#[test] +fn tombstoned_identity_orphan_rows_are_counted_per_row() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xCA); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x9A; 32]); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(id, w, 0)])), + identity_keys: Some(keys_changeset([ + key_entry(id, 0, 0x91, SecurityLevel::HIGH), + key_entry(id, 1, 0x92, SecurityLevel::HIGH), + key_entry(id, 2, 0x93, SecurityLevel::HIGH), + ])), + ..Default::default() + }, + ) + .unwrap(); + // Tombstone the owner; its three key rows are left behind. + let mut removed = IdentityChangeSet::default(); + removed.removed.insert(id); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(removed), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let recovery = platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(&path) + .with_load_policy(platform_wallet_storage::LoadPolicy::Recovery), + ) + .expect("reopen in recovery mode"); + recovery + .load() + .expect("recovery must complete the load despite orphan rows"); + assert_eq!( + recovery + .last_load_degradation() + .by_site + .get(&platform_wallet_storage::LoadSite::TombstonedIdentityOrphan) + .copied(), + Some(3), + "each leftover key row must be counted" + ); +} + +/// Cross-wallet scoping is preserved: two wallets each holding an +/// identity with the same `KeyID` get only their own key. +#[test] +fn tc10_cross_wallet_scoping_preserved() { + let (persister, _tmp, path) = fresh_persister(); + let wa = wid(0xAA); + let wb = wid(0xBB); + ensure_wallet_meta(&persister, &wa); + ensure_wallet_meta(&persister, &wb); + let ia = Identifier::from([0x1A; 32]); + let ib = Identifier::from([0x1B; 32]); + persister + .store( + wa, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(ia, wa, 0)])), + identity_keys: Some(keys_changeset([key_entry( + ia, + 0, + 0xAA, + SecurityLevel::HIGH, + )])), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + wb, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(ib, wb, 0)])), + identity_keys: Some(keys_changeset([key_entry( + ib, + 0, + 0xBB, + SecurityLevel::HIGH, + )])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let managed_a = &state.wallets[&wa].identity_manager.wallet_identities[&wa][&0]; + let managed_b = &state.wallets[&wb].identity_manager.wallet_identities[&wb][&0]; + assert_eq!(managed_a.identity.public_keys().len(), 1); + assert_eq!(managed_b.identity.public_keys().len(), 1); + assert_eq!( + managed_a + .identity + .public_keys() + .get(&0) + .unwrap() + .data() + .as_slice(), + &[0xAA; 33] + ); + assert_eq!( + managed_b + .identity + .public_keys() + .get(&0) + .unwrap() + .data() + .as_slice(), + &[0xBB; 33] + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs new file mode 100644 index 00000000000..cf385c7cbb4 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs @@ -0,0 +1,1208 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `core_address_pool` writer and read-time UTXO attribution. +//! +//! Covers pool rows with their `used` flags, the pool-resolved account index, +//! idempotent per-changeset pool state, and `key_class` survival. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::{AddressPoolType, AddressState, PublicKeyType}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Network, Utxo}; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, CoreChangeSet, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, + WalletMetadataEntry, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::{core_pool, core_state}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +/// Real external-pool `AddressInfo`s for a wallet's Standard BIP44 account 0, +/// sorted by derivation index — genuine scripts that round-trip. +fn external_infos(seed_byte: u8) -> Vec { + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_account_type(), + AccountType::Standard { index: 0, .. } + ) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let mut infos: Vec = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos; + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +fn utxo_on(info: &AddressInfo, value: u64) -> Utxo { + use dashcore::hashes::Hash; + Utxo { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([info.index as u8 ^ 0x5A; 32]), + vout: 0, + }, + txout: dashcore::TxOut { + value, + script_pubkey: info.script_pubkey.clone(), + }, + address: info.address.clone(), + height: 10, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +fn pool_entry( + account_type: AccountType, + pool_type: AddressPoolType, + addresses: Vec, +) -> AccountAddressPoolEntry { + AccountAddressPoolEntry { + account_type, + pool_type, + addresses, + } +} + +fn wallet_storage_error(err: PersistenceError) -> Box { + let source = match err { + PersistenceError::Backend { source, .. } => source, + other => panic!("expected Backend {{ .. }}, got {other:?}"), + }; + source + .downcast::() + .unwrap_or_else(|source| panic!("expected WalletStorageError, got {source}")) +} + +fn provider_platform_registration(wallet: &Wallet) -> ProviderKeyAccountEntry { + ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: ProviderKeyExtendedPubKey::EdDSA( + wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA platform account") + .ed25519_public_key + .clone(), + ), + } +} + +fn typed_platform_node_info(seed_byte: u8, index: u32, key_byte: u8) -> AddressInfo { + let mut info = external_infos(seed_byte) + .into_iter() + .nth(index as usize) + .expect("derived address at requested index"); + info.public_key = Some(PublicKeyType::EdDSA(vec![key_byte; 32])); + info +} + +fn provider_platform_pool_entry(addresses: Vec) -> AccountAddressPoolEntry { + pool_entry( + AccountType::ProviderPlatformKeys, + AddressPoolType::AbsentHardened, + addresses, + ) +} + +fn loaded_provider_platform_infos( + persister: &SqlitePersister, + wallet_id: &WalletId, +) -> Vec { + let state = persister.load().expect("load wallet state"); + let wallet_info = &state + .wallets + .get(wallet_id) + .expect("wallet rehydrated") + .wallet_info; + let account = wallet_info + .all_managed_accounts() + .into_iter() + .find(|managed| { + managed.managed_account_type().to_account_type() == AccountType::ProviderPlatformKeys + }) + .expect("restored platform-node managed account"); + account + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .expect("restored platform-node hardened pool") + .addresses + .values() + .cloned() + .collect() +} + +/// Six pool rows with `used` set on indices {0,2,4}; the pool +/// table is a first-class row store, not a `core_utxos` derivation. +#[test] +fn pool_rows_with_used_flags() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA0); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0x11); + infos.truncate(6); + assert_eq!(infos.len(), 6, "need at least six derived addresses"); + for info in infos.iter_mut() { + info.state = if matches!(info.index, 0 | 2 | 4) { + AddressState::Used + } else { + AddressState::Available + }; + } + let entry = pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + infos.clone(), + ); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![entry], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_index = 0 AND key_class = 0 AND pool_type = 0", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 6, "exactly six scoped rows"); + + for info in &infos { + let used: i64 = conn + .query_row( + "SELECT used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_index = 0 AND key_class = 0 \ + AND pool_type = 0 AND address_index = ?2", + rusqlite::params![w.as_slice(), i64::from(info.index)], + |r| r.get(0), + ) + .unwrap(); + let expect = i64::from(matches!(info.index, 0 | 2 | 4)); + assert_eq!(used, expect, "used flag for index {}", info.index); + } +} + +#[test] +fn reserved_address_persists_reservation_timestamp() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB0); + ensure_wallet_meta(&persister, &w); + + let mut info = external_infos(0xB0).remove(0); + let reserved_at = 1_752_528_623; + info.state = AddressState::Reserved { at: reserved_at }; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + vec![info], + )], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let (used, stored_reserved_at): (i64, Option) = conn + .query_row( + "SELECT used, reserved_at FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(used, 0); + assert_eq!(stored_reserved_at, Some(reserved_at as i64)); +} + +#[test] +fn available_and_used_addresses_persist_without_reservation_timestamp() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB1); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0xB1); + infos.truncate(2); + infos[0].state = AddressState::Available; + infos[1].state = AddressState::Used; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + infos, + )], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let mut stmt = conn + .prepare( + "SELECT used, reserved_at FROM core_address_pool \ + WHERE wallet_id = ?1 ORDER BY address_index", + ) + .unwrap(); + let rows = stmt + .query_map(rusqlite::params![w.as_slice()], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?)) + }) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(rows, vec![(0, None), (1, None)]); +} + +#[test] +fn used_address_cannot_regain_reservation_from_stale_snapshot() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB2); + ensure_wallet_meta(&persister, &w); + + let mut info = external_infos(0xB2).remove(0); + let account_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + info.state = AddressState::Used; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool_entry( + account_type, + AddressPoolType::External, + vec![info.clone()], + )], + ..Default::default() + }, + ) + .unwrap(); + + info.state = AddressState::Reserved { at: 1_752_528_624 }; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool_entry( + account_type, + AddressPoolType::External, + vec![info], + )], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let state: (i64, Option) = conn + .query_row( + "SELECT used, reserved_at FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(state, (1, None)); +} + +/// UTXOs resolve to their pool-declared account during reads. +#[test] +fn account_index_is_resolved_from_pool() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA2); + ensure_wallet_meta(&persister, &w); + + let infos = external_infos(0x22); + let addr0 = infos[0].clone(); + let addr1 = infos[1].clone(); + + // Pools declaring the address' owning account: addr0 -> account 0, + // addr1 -> account 1 (non-default). + let pools = vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + vec![addr0.clone()], + ), + pool_entry( + AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + vec![addr1.clone()], + ), + ]; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: pools, + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr0, 111), utxo_on(&addr1, 222)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + assert_eq!( + by_account.get(&1).map(|rows| rows[0].value), + Some(222), + "UTXO on account 1's address must resolve to account 1" + ); + assert_eq!( + by_account.get(&0).map(|rows| rows[0].value), + Some(111), + "UTXO on account 0's address must resolve to account 0" + ); +} + +/// A UTXO whose script matches no pool row resolves to the fallback account. +#[test] +fn utxo_without_pool_row_resolves_to_account_zero() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA3); + ensure_wallet_meta(&persister, &w); + + let infos = external_infos(0x33); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&infos[0], 500)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + assert_eq!(by_account.get(&0).map(|rows| rows[0].value), Some(500)); +} + +/// A used-flag flip persists and a second no-op flush leaves the +/// pool rows unchanged; `used` is monotonic and never reverts. +#[test] +fn pool_state_idempotent_and_monotonic() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA4); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0x44); + infos.truncate(3); + let mk = |infos: &[AddressInfo]| { + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + infos.to_vec(), + ) + }; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![mk(&infos)], + ..Default::default() + }, + ) + .unwrap(); + + // Flip index 1 to used. + let mut flipped = infos.clone(); + flipped[1].state = AddressState::Used; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![mk(&flipped)], + ..Default::default() + }, + ) + .unwrap(); + + let used_of = |conn: &rusqlite::Connection, idx: u32| -> i64 { + conn.query_row( + "SELECT used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_index = 0 AND pool_type = 0 AND address_index = ?2", + rusqlite::params![w.as_slice(), i64::from(idx)], + |r| r.get(0), + ) + .unwrap() + }; + { + let conn = persister.lock_conn_for_test(); + assert_eq!(used_of(&conn, 1), 1, "flip must persist"); + assert_eq!(used_of(&conn, 0), 0, "unrelated row unchanged"); + } + + // A stale snapshot with used=false for index 1 must NOT un-use it. + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![mk(&infos)], + ..Default::default() + }, + ) + .unwrap(); + let conn = persister.lock_conn_for_test(); + assert_eq!( + used_of(&conn, 1), + 1, + "used is monotonic — a stale snapshot never reverts it" + ); +} + +/// A non-default `key_class` round-trips into the pool row's PK. +#[test] +fn key_class_survives() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA5); + ensure_wallet_meta(&persister, &w); + + let infos = external_infos(0x55); + let entry = pool_entry( + AccountType::PlatformPayment { + account: 2, + key_class: 1, + }, + AddressPoolType::External, + vec![infos[0].clone()], + ); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![entry], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let (account_index, key_class): (i64, i64) = conn + .query_row( + "SELECT account_index, key_class FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(account_index, 2, "PlatformPayment account index"); + assert_eq!(key_class, 1, "non-default key_class must survive"); +} + +/// A single external `AddressInfo` at derivation index 0 for a seed, with a +/// chosen `used` flag. Two seeds yield distinct scripts so a cross-account +/// overwrite is observable. +fn index_zero_info(seed_byte: u8, used: bool) -> Vec { + let mut infos = external_infos(seed_byte); + infos.truncate(1); + infos[0].state = if used { + AddressState::Used + } else { + AddressState::Available + }; + infos +} + +/// Assert the pool rows for `(wallet, account_type)` are exactly `(script, +/// used)`, and that `total` rows exist for the wallet overall. +fn assert_pool_row( + persister: &platform_wallet_storage::SqlitePersister, + w: &WalletId, + label: &str, + want_script: &[u8], + want_used: i64, +) { + let conn = persister.lock_conn_for_test(); + let (script, used): (Vec, i64) = conn + .query_row( + "SELECT script, used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2", + rusqlite::params![w.as_slice(), label], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap_or_else(|e| panic!("expected exactly one row for {label}: {e}")); + assert_eq!(script, want_script, "{label} script must survive verbatim"); + assert_eq!(used, want_used, "{label} used flag must survive"); +} + +/// Two account types that both collapse to the `(account_index=0, +/// key_class=0)` sentinel — `IdentityRegistration` and `ProviderVotingKeys` — +/// must not overwrite each other's pool rows. Before the PK was widened with +/// `account_type` they upserted onto one PK tuple, silently losing one +/// account's `script` and merging `used`. +#[test] +fn distinct_account_types_sharing_index_zero_do_not_collide() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA6); + ensure_wallet_meta(&persister, &w); + + let id_reg = index_zero_info(0x61, true); + let prov = index_zero_info(0x62, false); + assert_ne!( + id_reg[0].script_pubkey, prov[0].script_pubkey, + "the two account types must carry distinct scripts to prove no overwrite" + ); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::IdentityRegistration, + AddressPoolType::External, + id_reg.clone(), + ), + pool_entry( + AccountType::ProviderVotingKeys, + AddressPoolType::External, + prov.clone(), + ), + ], + ..Default::default() + }, + ) + .unwrap(); + + assert_pool_row( + &persister, + &w, + "identity_registration", + id_reg[0].script_pubkey.as_bytes(), + 1, + ); + assert_pool_row( + &persister, + &w, + "provider_voting", + prov[0].script_pubkey.as_bytes(), + 0, + ); + let conn = persister.lock_conn_for_test(); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(total, 2, "both account types must persist as separate rows"); +} + +/// `Standard { index: 0 }` and `CoinJoin { index: 0 }` also both map to +/// `(account_index=0, key_class=0)` yet are distinct accounts; the +/// `account_type` discriminator (`standard_bip44` vs `coinjoin`) must keep +/// their pool rows separate. +#[test] +fn standard_and_coinjoin_index_zero_do_not_collide() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA7); + ensure_wallet_meta(&persister, &w); + + let std0 = index_zero_info(0x71, true); + let cj0 = index_zero_info(0x72, false); + assert_ne!( + std0[0].script_pubkey, cj0[0].script_pubkey, + "the two account types must carry distinct scripts to prove no overwrite" + ); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + std0.clone(), + ), + pool_entry( + AccountType::CoinJoin { index: 0 }, + AddressPoolType::External, + cj0.clone(), + ), + ], + ..Default::default() + }, + ) + .unwrap(); + + assert_pool_row( + &persister, + &w, + "standard_bip44", + std0[0].script_pubkey.as_bytes(), + 1, + ); + assert_pool_row( + &persister, + &w, + "coinjoin", + cj0[0].script_pubkey.as_bytes(), + 0, + ); +} + +/// Two DashPay contacts on one wallet both collapse to +/// `(account_type='dashpay_receiving', account_index=0, key_class=0)` — the +/// same `user_identity_id`, distinct `friend_identity_id`. Before the PK +/// carried the DashPay identity pair, the second contact's pool row would +/// silently overwrite the first's via `ON CONFLICT DO UPDATE`. +#[test] +fn distinct_dashpay_friends_do_not_collide_in_pool() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA8); + ensure_wallet_meta(&persister, &w); + + let user_identity_id = [0xABu8; 32]; + let friend_a = index_zero_info(0x81, true); + let friend_b = index_zero_info(0x82, false); + assert_ne!( + friend_a[0].script_pubkey, friend_b[0].script_pubkey, + "the two contacts must carry distinct scripts to prove no overwrite" + ); + + let dashpay_account = |friend_identity_id: [u8; 32]| AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id, + friend_identity_id, + }; + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + dashpay_account([0x01; 32]), + AddressPoolType::External, + friend_a.clone(), + ), + pool_entry( + dashpay_account([0x02; 32]), + AddressPoolType::External, + friend_b.clone(), + ), + ], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let assert_friend_row = |friend_identity_id: [u8; 32], want_script: &[u8], want_used: i64| { + let (script, used): (Vec, i64) = conn + .query_row( + "SELECT script, used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = 'dashpay_receiving' \ + AND user_identity_id = ?2 AND friend_identity_id = ?3", + rusqlite::params![w.as_slice(), &user_identity_id[..], &friend_identity_id[..]], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap_or_else(|e| { + panic!("expected exactly one row for friend {friend_identity_id:?}: {e}") + }); + assert_eq!( + script, want_script, + "contact's script must survive verbatim" + ); + assert_eq!(used, want_used, "contact's used flag must survive"); + }; + assert_friend_row([0x01; 32], friend_a[0].script_pubkey.as_bytes(), 1); + assert_friend_row([0x02; 32], friend_b[0].script_pubkey.as_bytes(), 0); + + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(total, 2, "both contacts must persist as separate rows"); +} + +/// Repro for a real gap found while merging PR #4117 with upstream PR #4127. +/// PR #4127 replaced the removed `derived_platform_node_keys` persistence with +/// generic `account_address_pools` snapshots, but SQLite's `core_pool.rs` and +/// `persister.rs` do not carry or restore the raw platform-node public key. +/// Reference: dashpay/platform#4113. +#[test] +fn platform_node_key_public_keys_survive_sqlite_store_and_load() { + let wallet = Wallet::from_seed_bytes( + [0x33u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let keys = platform_wallet::wallet::provider_key_at_index::derive_platform_node_public_keys( + &wallet, + Network::Testnet, + 3, + ) + .expect("derive"); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 0); + platform_wallet::wallet::provider_key_at_index::populate_platform_node_pool( + &mut wallet_info, + &keys, + Network::Testnet, + ) + .expect("populate"); + + let platform_node_account = wallet_info + .all_managed_accounts() + .into_iter() + .find(|managed| { + managed.managed_account_type().to_account_type() == AccountType::ProviderPlatformKeys + }) + .expect("platform-node managed account"); + let platform_node_pool = platform_node_account + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .expect("platform-node hardened pool"); + let addresses = platform_node_pool + .addresses + .values() + .cloned() + .collect::>(); + assert_eq!( + addresses.len(), + 3, + "the in-memory platform-node pool must contain all three derived keys" + ); + assert!( + addresses.iter().all(|info| info.public_key.is_some()), + "the in-memory platform-node pool must carry every derived public key" + ); + let pool_entry = AccountAddressPoolEntry { + account_type: AccountType::ProviderPlatformKeys, + pool_type: AddressPoolType::AbsentHardened, + addresses, + }; + let provider_registration = ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: ProviderKeyExtendedPubKey::EdDSA( + wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("eddsa account") + .ed25519_public_key + .clone(), + ), + }; + + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0x99); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_registration], + account_address_pools: vec![pool_entry], + ..Default::default() + }, + ) + .expect("store"); + drop(persister); + + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("reopen persister"); + let state = persister.load().expect("load"); + let restored = &state + .wallets + .get(&w) + .expect("wallet rehydrated") + .wallet_info; + let restored_platform_node_account = restored + .all_managed_accounts() + .into_iter() + .find(|managed| { + managed.managed_account_type().to_account_type() == AccountType::ProviderPlatformKeys + }) + .expect("restored platform-node managed account"); + let restored_platform_node_pool = restored_platform_node_account + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .expect("restored platform-node hardened pool"); + + assert_eq!( + restored_platform_node_pool.addresses.len(), + 3, + "all three platform-node indices must survive SQLite store()->load()" + ); + for key in &keys { + let restored_info = restored_platform_node_pool + .addresses + .get(&key.index) + .unwrap_or_else(|| panic!("platform-node index {} did not survive SQLite", key.index)); + let expected = Some(PublicKeyType::EdDSA(key.public_key.to_vec())); + assert_eq!( + restored_info.public_key, expected, + "platform-node public key at index {} did not survive SQLite store()->load() \ + — see doc comment: core_pool.rs never persists AddressInfo.public_key", + key.index + ); + } +} + +#[test] +fn conflicting_typed_pool_key_is_rejected_and_original_survives_load() { + let wallet = Wallet::from_seed_bytes( + [0xA1; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"); + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x9A); + let first = typed_platform_node_info(0xA2, 0, 0x11); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_platform_registration(&wallet)], + account_address_pools: vec![provider_platform_pool_entry(vec![first.clone()])], + ..Default::default() + }, + ) + .expect("store original typed pool key"); + + let fresh_sibling = typed_platform_node_info(0xA2, 1, 0x22); + let mut conflicting = first.clone(); + conflicting.public_key = Some(PublicKeyType::EdDSA(vec![0x33; 32])); + let err = persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![ + fresh_sibling, + conflicting, + ])], + ..Default::default() + }, + ) + .expect_err("a different typed key at the same pool index must be rejected"); + let storage_error = wallet_storage_error(err); + assert_eq!(storage_error.error_kind_str(), "typed_pool_key_conflict"); + + let restored = loaded_provider_platform_infos(&persister, &w); + assert_eq!(restored.len(), 1, "the rejected flush must be atomic"); + assert_eq!( + restored[0].public_key, first.public_key, + "the original typed key must remain intact" + ); +} + +#[test] +fn untyped_pool_key_cannot_overwrite_persisted_typed_key() { + let wallet = Wallet::from_seed_bytes( + [0xA3; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"); + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x9D); + let original = typed_platform_node_info(0xA4, 0, 0x71); + let mut untyped = original.clone(); + untyped.public_key = None; + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_platform_registration(&wallet)], + account_address_pools: vec![provider_platform_pool_entry(vec![untyped.clone()])], + ..Default::default() + }, + ) + .expect("store initial untyped pool row"); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![original.clone()])], + ..Default::default() + }, + ) + .expect("upgrade untyped pool row with typed key material"); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![untyped])], + ..Default::default() + }, + ) + .expect_err("an untyped row must not erase a persisted typed key"); + assert_eq!( + wallet_storage_error(err).error_kind_str(), + "typed_pool_key_conflict" + ); + + let restored = loaded_provider_platform_infos(&persister, &w); + assert_eq!(restored.len(), 1, "the rejected flush must be atomic"); + assert_eq!(restored[0].public_key, original.public_key); +} + +#[test] +fn malformed_typed_pool_key_widths_are_rejected_before_insert() { + let malformed_keys = [ + PublicKeyType::ECDSA(vec![0x11; 32]), + PublicKeyType::EdDSA(vec![0x22; 31]), + PublicKeyType::BLS(vec![0x33; 47]), + ]; + + for (case, malformed_key) in malformed_keys.into_iter().enumerate() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA0 + case as u8); + ensure_wallet_meta(&persister, &w); + let mut info = external_infos(0xA5 + case as u8) + .into_iter() + .next() + .expect("derived address"); + info.public_key = Some(malformed_key); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + vec![info], + )], + ..Default::default() + }, + ) + .expect_err("a malformed typed key must be rejected before commit"); + assert_eq!(wallet_storage_error(err).error_kind_str(), "blob_decode"); + + let conn = persister.lock_conn_for_test(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .expect("count pool rows"); + assert_eq!(count, 0, "malformed key case {case} reached the database"); + } +} + +#[test] +fn typed_pool_loader_rejects_mismatched_key_nullability() { + for (case, clear_column) in ["key_type", "public_key"].into_iter().enumerate() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB0 + case as u8); + ensure_wallet_meta(&persister, &w); + let account_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let mut info = external_infos(0xB5 + case as u8) + .into_iter() + .next() + .expect("derived address"); + info.public_key = Some(PublicKeyType::ECDSA(vec![0x44; 33])); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool_entry( + account_type, + AddressPoolType::External, + vec![info], + )], + ..Default::default() + }, + ) + .expect("store valid typed pool row"); + + let conn = persister.lock_conn_for_test(); + conn.execute( + &format!("UPDATE core_address_pool SET {clear_column} = NULL WHERE wallet_id = ?1"), + rusqlite::params![w.as_slice()], + ) + .expect("corrupt paired nullable columns"); + let err = + core_pool::load_typed_pool_entries(&conn, &w, &account_type, AddressPoolType::External) + .expect_err("mismatched typed-key nullability must fail hard"); + assert_eq!(err.error_kind_str(), "blob_decode", "case {clear_column}"); + } +} + +#[test] +fn identical_typed_pool_key_is_idempotent() { + let wallet = Wallet::from_seed_bytes( + [0xB1; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"); + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x9B); + let info = typed_platform_node_info(0xB2, 0, 0x44); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_platform_registration(&wallet)], + account_address_pools: vec![provider_platform_pool_entry(vec![info.clone()])], + ..Default::default() + }, + ) + .expect("store original typed pool key"); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![info.clone()])], + ..Default::default() + }, + ) + .expect("re-store identical typed pool key"); + + let restored = loaded_provider_platform_infos(&persister, &w); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].public_key, info.public_key); +} + +#[test] +fn typed_pool_key_at_fresh_index_succeeds() { + let wallet = Wallet::from_seed_bytes( + [0xC1; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"); + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x9C); + let first = typed_platform_node_info(0xC2, 0, 0x55); + let fresh = typed_platform_node_info(0xC2, 1, 0x66); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + provider_key_account_registrations: vec![provider_platform_registration(&wallet)], + account_address_pools: vec![provider_platform_pool_entry(vec![first])], + ..Default::default() + }, + ) + .expect("store initial typed pool key"); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![provider_platform_pool_entry(vec![fresh.clone()])], + ..Default::default() + }, + ) + .expect("store typed pool key at a fresh index"); + + let restored = loaded_provider_platform_infos(&persister, &w); + assert_eq!(restored.len(), 2); + assert_eq!( + restored + .iter() + .find(|info| info.index == fresh.index) + .expect("fresh index restored") + .public_key, + fresh.public_key + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs new file mode 100644 index 00000000000..1efad56d87f --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -0,0 +1,780 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `schema::core_state::load_state` bulk-reconstructs the keyless +//! `CoreChangeSet` (UTXOs, records, IS-locks, sync watermarks), and the +//! no-silent-zero balance contract holds end-to-end. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::hashes::Hash; +use dashcore::{OutPoint, Txid}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::Utxo; +use platform_wallet::changeset::AccountRegistrationEntry; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::core_state; +use platform_wallet_storage::LoadCtx; +use platform_wallet_storage::WalletStorageError; + +/// Keyless account manifest the rehydration path resolves xpubs from. +fn manifest_for(wallet: &Wallet) -> Vec { + wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect() +} + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +/// Build a wallet + a UTXO paying one of its BIP44 addresses, value +/// `value`, confirmed at `height`. +fn wallet_and_utxo(seed: [u8; 64], value: u64, height: u32, vout: u32) -> (Wallet, Utxo) { + let w = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&w, 1); + // Any monitored address of the wallet — what a real UTXO would pay. + let address = WalletInfoInterface::monitored_addresses(&info) + .into_iter() + .next() + .expect("at least one monitored address"); + let script = address.script_pubkey(); + let utxo = Utxo { + outpoint: OutPoint { + txid: Txid::from_byte_array([0x55; 32]), + vout, + }, + txout: dashcore::TxOut { + value, + script_pubkey: script, + }, + address, + height, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + (w, utxo) +} + +/// A non-zero balance survives store → drop → reopen → load, guarding +/// against a silent-zero-balance reconstruction. +#[test] +fn rt2_nonzero_balance_survives_reopen() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB1); + ensure_wallet_meta(&persister, &w); + + let seed = [0x42; 64]; + let (wallet, utxo) = wallet_and_utxo(seed, 1_234_500, 100, 0); + + let cs = PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo.clone()], + last_processed_height: Some(200), + synced_height: Some(200), + ..Default::default() + }), + ..Default::default() + }; + persister.store(w, cs).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let (core, utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .expect("load_state"); + drop(conn); + + // The persisted UTXO round-trips by outpoint + value. + assert_eq!(core.new_utxos.len(), 1); + assert_eq!(core.new_utxos[0].outpoint, utxo.outpoint); + assert_eq!(core.new_utxos[0].value(), 1_234_500); + assert_eq!(core.last_processed_height, Some(200)); + assert_eq!(core.synced_height, Some(200)); + + // End-to-end: apply the loaded state onto a freshly minted skeleton and + // assert the wallet balance is the persisted amount — NOT a silent zero. + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + platform_wallet_storage::sqlite::rehydrate::apply_persisted_core_state( + &mut info, + &manifest_for(&wallet), + &core, + &utxo_accounts, + &Default::default(), + &LoadCtx::strict(), + ) + .expect("BIP44 reconstruction must not error"); + let bal = WalletInfoInterface::balance(&info); + let total = bal.confirmed() + bal.unconfirmed() + bal.immature() + bal.locked(); + assert_eq!( + total, 1_234_500, + "reconstructed wallet balance must be exact" + ); + assert!(total > 0, "silent zero balance is a FAIL"); + // Height-bearing UTXO lands in the confirmed bucket. + assert_eq!(bal.confirmed(), 1_234_500); +} + +/// Spent UTXOs are excluded from the reconstructed feed. +#[test] +fn b2_spent_utxo_excluded() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB2); + ensure_wallet_meta(&persister, &w); + let seed = [0x07; 64]; + let (_w, u_unspent) = wallet_and_utxo(seed, 1000, 10, 0); + let (_w2, u_spent) = wallet_and_utxo(seed, 9999, 10, 1); + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![u_unspent.clone()], + spent_utxos: vec![u_spent.clone()], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let (core, _utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .unwrap(); + drop(conn); + let ops: Vec<_> = core.new_utxos.iter().map(|u| u.outpoint).collect(); + assert!(ops.contains(&u_unspent.outpoint)); + assert!( + !ops.contains(&u_spent.outpoint), + "spent UTXO must not resurrect on reload" + ); +} + +/// A corrupt `record_blob` is a typed hard error. +#[test] +fn b3_corrupt_record_blob_is_hard_error() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB3); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, NULL, NULL, NULL, 0, X'00')", + rusqlite::params![w.as_slice(), &[0x11u8; 32][..]], + ) + .unwrap(); + } + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let result = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()); + drop(conn); + assert!( + matches!(result, Err(WalletStorageError::BincodeDecode { .. })), + "corrupt record_blob must be a typed BincodeDecode; got {result:?}" + ); +} + +/// A CoinJoin-only wallet (no BIP44 account) with non-zero persisted +/// UTXOs reconstructs to the correct non-zero total, never a silent +/// `Ok` + 0. +#[test] +fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { + use std::collections::BTreeSet; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xBF); + ensure_wallet_meta(&persister, &w); + + // CoinJoin-only topology: empty BIP44/BIP32 sets, one CoinJoin + // account, no special accounts. + let mut coinjoin = BTreeSet::new(); + coinjoin.insert(0u32); + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + coinjoin, + BTreeSet::new(), + BTreeSet::new(), + None, + ); + let seed = [0x4F; 64]; + let wallet = Wallet::from_seed_bytes(seed, key_wallet::Network::Testnet, opts).unwrap(); + assert!( + wallet.accounts.standard_bip44_accounts.is_empty(), + "fixture must be BIP44-free to exercise F2" + ); + let info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + info.accounts.standard_bip44_accounts.is_empty() + && !info.accounts.coinjoin_accounts.is_empty(), + "managed info must be CoinJoin-only" + ); + let address = WalletInfoInterface::monitored_addresses(&info) + .into_iter() + .next() + .expect("CoinJoin-only wallet still has monitored addresses"); + + let utxo = Utxo { + outpoint: OutPoint { + txid: Txid::from_byte_array([0x77; 32]), + vout: 0, + }, + txout: dashcore::TxOut { + value: 9_000_000, + script_pubkey: address.script_pubkey(), + }, + address, + height: 50, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo.clone()], + last_processed_height: Some(60), + synced_height: Some(60), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let (core, utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .unwrap(); + drop(conn); + assert_eq!(core.new_utxos.len(), 1); + + // Apply leg: reconstruct onto a fresh skeleton and check the total. + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + platform_wallet_storage::sqlite::rehydrate::apply_persisted_core_state( + &mut info, + &manifest_for(&wallet), + &core, + &utxo_accounts, + &Default::default(), + &LoadCtx::strict(), + ) + .expect("CoinJoin-only reconstruction must not error"); + let bal = WalletInfoInterface::balance(&info); + let total = bal.confirmed() + bal.unconfirmed() + bal.immature() + bal.locked(); + assert_eq!( + total, 9_000_000, + "CoinJoin-only wallet must reconstruct the exact non-zero total — \ + a silent zero is a FAIL" + ); +} + +/// Empty wallet → empty core state, no error. +#[test] +fn b4_empty_core_state_is_ok() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB4); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let (core, _utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .unwrap(); + drop(conn); + assert!(core.new_utxos.is_empty()); + assert!(core.records.is_empty()); + assert_eq!(core.last_processed_height, None); +} + +/// `last_applied_chain_lock` persists through flush → reopen → `load_state` +/// and through the higher-level `PlatformWalletPersistence::load()` path. +/// +/// Adversarial confirmation: the assertion at the end fails if the reader +/// `load_state` does NOT populate `cs.last_applied_chain_lock` (i.e. if +/// the old code path "left None" is still in place). +#[test] +fn b5_last_applied_chain_lock_round_trips() { + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::hashes::Hash; + use dashcore::BlockHash; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB5); + ensure_wallet_meta(&persister, &w); + + // Construct a deterministic ChainLock. + let cl = ChainLock { + block_height: 88_888, + block_hash: BlockHash::from_byte_array([0xCAu8; 32]), + signature: [0xBBu8; 96].into(), + }; + + // Persist via the normal store → flush path. + let cs = PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(cl.clone()), + synced_height: Some(88_888), + ..Default::default() + }), + ..Default::default() + }; + persister.store(w, cs).expect("store"); + PlatformWalletPersistence::flush(&persister, w).expect("flush"); + drop(persister); + + // Reopen and read via `core_state::load_state` directly. + let p2 = reopen(&path); + { + let conn = p2.lock_conn_for_test(); + let (loaded, _utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .expect("load_state must succeed"); + assert_eq!( + loaded.last_applied_chain_lock.as_ref(), + Some(&cl), + "core_state::load_state must populate last_applied_chain_lock from disk" + ); + // Other fields carried by the same row must also survive. + assert_eq!(loaded.synced_height, Some(88_888)); + } + drop(p2); + + // Adversarial path: `PlatformWalletPersistence::load()` must also surface + // the chain lock through the assembled `core_wallet_info` metadata. + let p3 = reopen(&path); + let start_state = PlatformWalletPersistence::load(&p3).expect("load must succeed"); + let wallet_start = start_state + .wallets + .get(&w) + .expect("wallet must be in load output"); + assert_eq!( + wallet_start + .wallet_info + .metadata + .last_applied_chain_lock + .as_ref(), + Some(&cl), + "PlatformWalletPersistence::load must carry last_applied_chain_lock \ + into the assembled core_wallet_info metadata" + ); +} + +/// A lower-height chain lock arriving AFTER a higher one must not regress the +/// stored `last_applied_chain_lock`: heights monotonic-max merge just like the +/// sync watermarks, so an out-of-order update can't roll the finalized +/// checkpoint backwards. +#[test] +fn chain_lock_does_not_regress_on_lower_height_update() { + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::BlockHash; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB6); + ensure_wallet_meta(&persister, &w); + + let high = ChainLock { + block_height: 100_000, + block_hash: BlockHash::from_byte_array([0xAAu8; 32]), + signature: [0x11u8; 96].into(), + }; + let low = ChainLock { + block_height: 90_000, + block_hash: BlockHash::from_byte_array([0xBBu8; 32]), + signature: [0x22u8; 96].into(), + }; + + let store_cl = |cl: ChainLock| { + let cs = PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(cl), + ..Default::default() + }), + ..Default::default() + }; + persister.store(w, cs).expect("store"); + PlatformWalletPersistence::flush(&persister, w).expect("flush"); + }; + store_cl(high.clone()); + store_cl(low); // out-of-order, lower height — must not win + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let (loaded, _utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .expect("load_state must succeed"); + assert_eq!( + loaded.last_applied_chain_lock.as_ref(), + Some(&high), + "a lower-height chain lock must not regress the stored higher one" + ); +} + +/// First external `AddressInfo` of the account matching `pred` in `wallet`, +/// sorted by derivation index — a genuine script that round-trips. +fn first_external_info( + wallet: &Wallet, + pred: impl Fn(&key_wallet::account::AccountType) -> bool, +) -> key_wallet::AddressInfo { + use key_wallet::managed_account::address_pool::AddressPoolType; + let info = ManagedWalletInfo::from_wallet(wallet, 0); + for managed in info.all_managed_accounts() { + if !pred(&managed.managed_account_type().to_account_type()) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let mut infos: Vec = + pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos.into_iter().next().unwrap(); + } + } + panic!("wallet must expose the requested account with a non-empty external pool"); +} + +/// End-to-end regression (dashpay/platform#3968) exercising the REAL SQL +/// resolver. Persists a `Default` wallet through the actual writer with unspent +/// UTXOs owned by Standard BIP44[0] and CoinJoin[0] — colliding on numeric +/// index 0 — plus their `core_address_pool` snapshots, reopens the DB, then +/// drives `load_state` → `apply_persisted_core_state`. Unlike the hand-built +/// unit test, the owning-account side channel here is produced by +/// `owning_account_for_script` (column order, `ORDER BY` tie-break, `[u8;32]` +/// identity decode), so a broken query would be caught. Each UTXO must land in +/// its TRUE account with exact per-account balances, not just the wallet total. +#[test] +fn rehydration_routes_via_real_sql_resolver() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::address_pool::AddressPoolType; + use platform_wallet::changeset::AccountAddressPoolEntry; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + + let wallet = Wallet::from_seed_bytes( + [0x9A; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + + let bip44_info = first_external_info(&wallet, |at| { + matches!( + at, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } + ) + }); + let coinjoin_info = first_external_info(&wallet, |at| { + matches!(at, AccountType::CoinJoin { index: 0 }) + }); + assert_ne!( + bip44_info.script_pubkey, coinjoin_info.script_pubkey, + "BIP44[0] and CoinJoin[0] must derive distinct scripts" + ); + + let utxo_on = |info: &key_wallet::AddressInfo, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from_byte_array([n; 32]), + vout: 0, + }, + txout: dashcore::TxOut { + value, + script_pubkey: info.script_pubkey.clone(), + }, + address: info.address.clone(), + height: 5, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + let bip44_utxo = utxo_on(&bip44_info, 5_000, 1); + let coinjoin_utxo = utxo_on(&coinjoin_info, 7_000, 2); + let bip44_op = bip44_utxo.outpoint; + let coinjoin_op = coinjoin_utxo.outpoint; + + let pool_entry = |account_type, info: &key_wallet::AddressInfo| AccountAddressPoolEntry { + account_type, + pool_type: AddressPoolType::External, + addresses: vec![info.clone()], + }; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + &bip44_info, + ), + pool_entry(AccountType::CoinJoin { index: 0 }, &coinjoin_info), + ], + core: Some(CoreChangeSet { + new_utxos: vec![bip44_utxo, coinjoin_utxo], + last_processed_height: Some(5), + synced_height: Some(5), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("store must persist UTXOs + pool snapshots"); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let (core, utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .expect("load_state"); + drop(conn); + + // The real resolver populated the side channel from `core_address_pool`. + assert_eq!( + utxo_accounts.len(), + 2, + "owning_account_for_script must resolve both UTXOs from the persisted pool" + ); + + let mut managed = ManagedWalletInfo::from_wallet(&wallet, 1); + platform_wallet_storage::sqlite::rehydrate::apply_persisted_core_state( + &mut managed, + &manifest_for(&wallet), + &core, + &utxo_accounts, + &Default::default(), + &LoadCtx::strict(), + ) + .expect("apply must not error"); + + let bip44 = managed.accounts.standard_bip44_accounts.get(&0).unwrap(); + let coinjoin = managed.accounts.coinjoin_accounts.get(&0).unwrap(); + assert!( + bip44.utxos.contains_key(&bip44_op), + "BIP44 UTXO must route to the BIP44 account" + ); + assert!( + !bip44.utxos.contains_key(&coinjoin_op), + "CoinJoin UTXO must NOT collapse onto the first (BIP44) account" + ); + assert!( + coinjoin.utxos.contains_key(&coinjoin_op), + "CoinJoin UTXO must route to the CoinJoin account" + ); + assert!(!coinjoin.utxos.contains_key(&bip44_op)); + assert_eq!( + bip44.balance.total(), + 5_000, + "per-account BIP44 balance exact" + ); + assert_eq!( + coinjoin.balance.total(), + 7_000, + "per-account CoinJoin balance exact, not zero" + ); + assert_eq!(managed.balance.total(), 12_000, "wallet total is the sum"); +} + +/// End-to-end regression (dashpay/platform#3968) for the address-reuse guard, +/// exercising the REAL SQL resolver. Persists a `Default` wallet with a *used* +/// address (via a `core_address_pool` snapshot with `used = true`) owned by +/// CoinJoin[0] — which is NOT the first funds account (Standard BIP44[0] is) — +/// with no unspent UTXO anchoring it. Reopens the DB, unions the two +/// used-address sources exactly as the persister does (so +/// `core_pool::load_used_addresses` carries the owner), then drives +/// `apply_persisted_core_state`. The used address must land `used` on the +/// CoinJoin pool specifically — never collapsed onto BIP44 — or it stays +/// "unused" on CoinJoin and could be re-issued as a fresh receive address. +#[test] +fn rehydration_routes_used_addresses_to_owning_account() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::address_pool::{AddressPoolType, AddressState}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use platform_wallet::changeset::AccountAddressPoolEntry; + use platform_wallet_storage::sqlite::schema::core_pool::OwningAccount; + use platform_wallet_storage::sqlite::schema::{core_pool, core_state}; + use std::collections::HashMap; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + + let wallet = Wallet::from_seed_bytes( + [0x9B; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + + // CoinJoin[0] external index-0 address, marked used in the snapshot. + let mut coinjoin_used = first_external_info(&wallet, |at| { + matches!(at, AccountType::CoinJoin { index: 0 }) + }); + coinjoin_used.state = AddressState::Used; + let bip44_info = first_external_info(&wallet, |at| { + matches!( + at, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } + ) + }); + assert_ne!( + bip44_info.script_pubkey, coinjoin_used.script_pubkey, + "BIP44[0] and CoinJoin[0] must derive distinct scripts" + ); + + let pool_entry = |account_type, info: &key_wallet::AddressInfo| AccountAddressPoolEntry { + account_type, + pool_type: AddressPoolType::External, + addresses: vec![info.clone()], + }; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + &bip44_info, + ), + pool_entry(AccountType::CoinJoin { index: 0 }, &coinjoin_used), + ], + ..Default::default() + }, + ) + .expect("store must persist the pool snapshot"); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let (core, utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .expect("load_state"); + + // Union the two used-address sources exactly as the persister does — the + // pool source carries the known owner (CoinJoin), authoritative on conflict. + let used: HashMap> = { + let mut map: HashMap> = HashMap::new(); + for (addr, owner) in core_pool::load_used_addresses_with_ctx( + &conn, + &w, + key_wallet::Network::Testnet, + &LoadCtx::strict(), + ) + .expect("core_pool used addresses") + { + map.entry(addr).or_insert(Some(owner)); + } + for (addr, owner) in core_state::load_used_addresses_with_ctx( + &conn, + &w, + key_wallet::Network::Testnet, + &LoadCtx::strict(), + ) + .expect("core_utxos used addresses") + { + map.entry(addr).or_insert(owner); + } + map + }; + drop(conn); + + // The real pool resolver attributed the used address to CoinJoin[0]. + assert_eq!( + used.get(&coinjoin_used.address), + Some(&Some(OwningAccount { + account_type: "coinjoin".to_string(), + account_index: 0, + user_identity_id: [0u8; 32], + friend_identity_id: [0u8; 32], + })), + "the used address must resolve to the CoinJoin owner from the pool" + ); + + let mut managed = ManagedWalletInfo::from_wallet(&wallet, 1); + platform_wallet_storage::sqlite::rehydrate::apply_persisted_core_state( + &mut managed, + &manifest_for(&wallet), + &core, + &utxo_accounts, + &used, + &LoadCtx::strict(), + ) + .expect("apply must not error"); + + // The used address is marked used on the CoinJoin pool specifically. + let coinjoin = managed.accounts.coinjoin_accounts.get(&0).unwrap(); + let cj_external = coinjoin + .managed_account_type() + .address_pools() + .into_iter() + .find(|p| p.pool_type == AddressPoolType::External) + .expect("CoinJoin External pool"); + assert!( + cj_external + .address_info(&coinjoin_used.address) + .expect("used address present in the CoinJoin pool") + .is_used(), + "used CoinJoin address must be marked used on the CoinJoin pool, not BIP44" + ); + + // It must NOT have been (mis)routed onto the first (BIP44) account. + let bip44 = managed.accounts.standard_bip44_accounts.get(&0).unwrap(); + for pool in bip44.managed_account_type().address_pools() { + assert!( + pool.address_info(&coinjoin_used.address).is_none(), + "the CoinJoin used address must not appear in any BIP44 pool" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs b/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs new file mode 100644 index 00000000000..2b50173b838 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs @@ -0,0 +1,108 @@ +#![allow(clippy::field_reassign_with_default)] + +//! DashPay write-only overlay contract. +//! +//! `dashpay_profiles` / `dashpay_payments_overlay` are a write-only +//! indexed overlay: data written via the dedicated `dashpay_*` changeset +//! slots IS persisted to the tables, but `load()` rehydrates DashPay +//! state from the identities `entry_blob`, NOT from these tables. These +//! tests pin both halves of that contract: +//! +//! 1. A `dashpay_*` write lands in the overlay tables (queryable directly). +//! 2. Writing ONLY the overlay (no identity blob carrying the same data) +//! does not corrupt `load()` — load succeeds and surfaces the wallet's +//! other state intact. + +mod common; + +use std::collections::BTreeMap; + +use common::{ensure_identity, ensure_wallet_meta, fresh_persister, wid}; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet::wallet::identity::DashPayProfile; + +fn profile(name: &str) -> DashPayProfile { + DashPayProfile { + display_name: Some(name.to_string()), + ..Default::default() + } +} + +#[test] +fn dashpay_overlay_write_is_persisted_to_its_table() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA1); + let identity = [0xA2u8; 32]; + ensure_wallet_meta(&persister, &w); + ensure_identity(&persister, &identity, Some(&w)); + + let mut profiles: BTreeMap> = BTreeMap::new(); + profiles.insert(Identifier::from(identity), Some(profile("alice"))); + + let mut cs = PlatformWalletChangeSet::default(); + cs.dashpay_profiles = Some(profiles); + persister.store(w, cs).expect("store dashpay profile"); + persister.flush(w).expect("flush"); + + // The overlay row is physically present in its dedicated table. + let conn = persister.lock_conn_for_test(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM dashpay_profiles WHERE identity_id = ?1", + rusqlite::params![&identity[..]], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "dashpay_profiles overlay row must be persisted"); +} + +#[test] +fn overlay_only_write_does_not_corrupt_load() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xB1); + let identity = [0xB2u8; 32]; + ensure_wallet_meta(&persister, &w); + ensure_identity(&persister, &identity, Some(&w)); + + // Give the wallet real, loadable core state plus an overlay-only + // DashPay write (no identity blob carries this profile). + let mut core_cs = PlatformWalletChangeSet::default(); + core_cs.wallet_metadata = Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + wallet_group_id: w, + birth_height: 0, + }); + core_cs.core = Some(CoreChangeSet { + synced_height: Some(99), + last_processed_height: Some(99), + ..Default::default() + }); + persister.store(w, core_cs).expect("store core"); + persister.flush(w).expect("flush core"); + + let mut profiles: BTreeMap> = BTreeMap::new(); + profiles.insert(Identifier::from(identity), Some(profile("bob"))); + let mut overlay_cs = PlatformWalletChangeSet::default(); + overlay_cs.dashpay_profiles = Some(profiles); + persister.store(w, overlay_cs).expect("store overlay"); + persister.flush(w).expect("flush overlay"); + + // The documented contract: load() reads DashPay from the identities + // blob (not the overlay table), so the overlay-only write neither + // appears in nor corrupts the loaded state. load() must still + // succeed and surface the wallet's core state. + let state = persister + .load() + .expect("load must succeed despite overlay-only write"); + let wallet = state + .wallets + .get(&w) + .expect("wallet present in loaded state"); + assert_eq!( + wallet.wallet_info.metadata.synced_height, 99, + "core state must rehydrate intact alongside an unread overlay" + ); +} 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 index bfe7b455b05..f74d117ea87 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs @@ -88,7 +88,7 @@ fn buffered_only_delete_is_ok_and_no_resurrection() { /// whose only state lived in the buffer. #[test] fn pre_delete_backup_includes_buffered_writes() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let path = tmp.path().join("w.db"); let backup_dir = tmp.path().join("backups"); let cfg = SqlitePersisterConfig::new(&path) @@ -116,7 +116,7 @@ fn pre_delete_backup_includes_buffered_writes() { .unwrap(); let in_backup_meta: Option = backup .query_row( - "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| row.get(0), ) @@ -130,7 +130,7 @@ fn pre_delete_backup_includes_buffered_writes() { assert_eq!( in_backup_meta, Some(1), - "pre-delete backup must contain the flushed buffered wallet_metadata row" + "pre-delete backup must contain the flushed buffered wallets row" ); } @@ -139,7 +139,7 @@ fn pre_delete_backup_includes_buffered_writes() { /// `delete_wallet` surfaces the original error. #[test] fn pre_flush_failure_preserves_buffer_and_skips_backup() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let path = tmp.path().join("w.db"); let backup_dir = tmp.path().join("backups"); let cfg = SqlitePersisterConfig::new(&path) @@ -148,11 +148,11 @@ fn pre_flush_failure_preserves_buffer_and_skips_backup() { let persister = SqlitePersister::open(cfg).unwrap(); let w = wid(0xC1); - // Seed wallet_metadata so the wallet exists in the live DB. + // Seed wallets 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) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", rusqlite::params![w.as_slice()], ) @@ -186,7 +186,7 @@ fn pre_flush_failure_preserves_buffer_and_skips_backup() { let meta_rows: i64 = { let conn = persister.lock_conn_for_test(); conn.query_row( - "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| row.get(0), ) 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 index 4ac4dbbcab0..ee25313002c 100644 --- 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 @@ -8,7 +8,7 @@ mod common; use common::{ensure_wallet_meta, fresh_persister, wid}; -use rusqlite::TransactionBehavior; +use rusqlite::{OptionalExtension as _, TransactionBehavior}; /// When a peer holds EXCLUSIVE on the destination, `delete_wallet` /// must block / fail on busy rather than proceeding through an @@ -19,10 +19,9 @@ fn delete_wallet_blocks_when_peer_holds_exclusive() { 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. + let backup_dir = common::secure_tempdir().expect("backup dir"); + // Re-open with auto-backup wired so delete_wallet exercises the + // backup + cascade path (the canonical path under test). drop(persister); let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&db_path) .with_auto_backup_dir(Some(backup_dir.path().to_path_buf())); @@ -78,7 +77,7 @@ fn delete_wallet_blocks_when_peer_holds_exclusive() { #[test] fn delete_wallet_single_process_still_works() { let (persister, _tmp, db_path) = fresh_persister(); - let backup_dir = tempfile::tempdir().expect("backup dir"); + let backup_dir = common::secure_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())); @@ -89,14 +88,15 @@ fn delete_wallet_single_process_still_works() { 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. + // wallets 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", + "SELECT 1 FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |r| r.get(0), ) - .ok(); - assert!(row.is_none(), "wallet_metadata row must be gone"); + .optional() + .expect("wallets query must not fail — only absence is expected"); + assert!(row.is_none(), "wallets row must be gone"); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs new file mode 100644 index 00000000000..363c53f88a5 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs @@ -0,0 +1,162 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Coverage for `delete_wallet_inner`'s two-transaction shape: the +//! pre-flush tx (drains + commits the buffered changeset) and the cascade +//! tx (deletes the parent `wallets` row) are SEPARATE SQLite +//! transactions. These tests probe what is durable on disk and what is +//! left in the buffer when the delete aborts AFTER the pre-flush has +//! already committed its changeset. + +mod common; + +use common::wid; +use key_wallet::Network; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet_storage::{FlushMode, SqlitePersister, SqlitePersisterConfig}; +use rusqlite::TransactionBehavior; + +/// Self-consistent changeset that materializes a brand-new wallet on +/// flush (FK-valid `wallets` row + a `core_sync_state` child row). +fn full_changeset(synced: u32) -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet::default(); + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 0, + }); + cs.core = Some(CoreChangeSet { + synced_height: Some(synced), + last_processed_height: Some(synced), + ..Default::default() + }); + cs +} + +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() +} + +fn wallets_rows_for(persister: &SqlitePersister, w: &[u8; 32]) -> i64 { + let conn = persister.lock_conn_for_test(); + conn.query_row( + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap() +} + +/// A peer holds a SQLite-native EXCLUSIVE on the same DB file, so the +/// pre-flush's own `BEGIN EXCLUSIVE` fails with BUSY (real +/// `?`-propagation, not the injector) and the cascade is never reached. +/// On that failure the buffered changeset must survive — either still in +/// the buffer (restored) or already durable on disk. +#[test] +fn preflush_begin_exclusive_busy_preserves_buffer() { + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("w.db"); + // No auto_backup_dir + skip_backup keeps the test on the + // pre-flush -> cascade path without a backup dependency. + let cfg = SqlitePersisterConfig::new(&path).with_flush_mode(FlushMode::Manual); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xD1); + + // Buffer a brand-new wallet's full changeset (only state is buffered). + persister.store(w, full_changeset(42)).unwrap(); + + // A peer process grabs EXCLUSIVE on the same file and holds it, + // forcing the persister's own `BEGIN EXCLUSIVE` (pre-flush AND + // cascade both use it) to fail with BUSY past the busy_timeout. + let mut peer = rusqlite::Connection::open(&path).unwrap(); + let peer_guard = peer + .transaction_with_behavior(TransactionBehavior::Exclusive) + .expect("peer EXCLUSIVE"); + + let err = persister.delete_wallet_skip_backup(w); + assert!( + err.is_err(), + "delete must fail while a peer holds EXCLUSIVE; got {err:?}" + ); + + drop(peer_guard); + drop(peer); + + // Contract: a failed delete must not have removed the wallet. Either + // the wallet's state is still buffered (pre-flush never committed) OR + // it is durable on disk (pre-flush committed before the cascade + // aborted). Both are acceptable per the two-tx design; what is NOT + // acceptable is the changeset vanishing from BOTH the buffer and disk. + let on_disk_core = core_rows_for(&persister, &w); + let on_disk_wallets = wallets_rows_for(&persister, &w); + let in_buffer = persister.buffer_has_changeset_for_test(&w); + + assert!( + in_buffer || (on_disk_wallets == 1 && on_disk_core == 1), + "after a failed delete the buffered changeset must survive somewhere: \ + in_buffer={in_buffer}, on_disk_wallets={on_disk_wallets}, on_disk_core={on_disk_core}" + ); + + // If it is on disk, the wallet must NOT have been deleted (delete + // returned Err) — i.e. the cascade did not run. + if on_disk_wallets == 1 { + assert_eq!( + on_disk_core, 1, + "pre-flush committed the wallet but its child row is missing — \ + partial pre-flush is a torn write" + ); + } +} + +/// A pre-flush-committed changeset is durable even though `delete_wallet` +/// aborts; a clean retry once the peer lock is gone converges to a fully +/// deleted wallet. +#[test] +fn delete_retry_after_transient_abort_converges_to_deleted() { + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path).with_flush_mode(FlushMode::Manual); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xD2); + + persister.store(w, full_changeset(7)).unwrap(); + + { + let mut peer = rusqlite::Connection::open(&path).unwrap(); + let _peer_guard = peer + .transaction_with_behavior(TransactionBehavior::Exclusive) + .expect("peer EXCLUSIVE"); + let first = persister.delete_wallet_skip_backup(w); + assert!(first.is_err(), "first delete must fail under peer lock"); + // peer guard drops here, releasing the lock + } + + // Retry with the lock gone: the wallet must end fully deleted + // regardless of whether the first attempt left state on disk or in + // the buffer. + persister + .delete_wallet_skip_backup(w) + .expect("retry delete must succeed once the peer lock is gone"); + + persister + .commit_writes() + .expect("commit_writes drains buffer"); + + assert_eq!( + wallets_rows_for(&persister, &w), + 0, + "wallet parent row must be gone after a converged delete" + ); + assert_eq!( + core_rows_for(&persister, &w), + 0, + "wallet child rows must be gone after a converged delete" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs new file mode 100644 index 00000000000..e6b67cccf17 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs @@ -0,0 +1,73 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `delete_wallet` pre-flush apply failure driven by a REAL SQL error, +//! exercising the apply / commit restore branches a test injector cannot. +//! +//! Strategy: buffer a wallet's full changeset in `Manual` mode, then drop +//! a child table the pre-flush apply needs (`core_sync_state`) via a side +//! connection. The delete's pre-flush `apply_changeset_to_tx` then hits a +//! real "no such table" failure on the core-state INSERT, and the +//! buffered changeset MUST be restored (not lost). + +mod common; + +use common::wid; +use key_wallet::Network; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet_storage::{FlushMode, SqlitePersister, SqlitePersisterConfig}; + +fn full_changeset(synced: u32) -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet::default(); + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 0, + }); + cs.core = Some(CoreChangeSet { + synced_height: Some(synced), + last_processed_height: Some(synced), + ..Default::default() + }); + cs +} + +#[test] +fn delete_wallet_pre_flush_apply_real_sql_failure_restores_buffer() { + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path).with_flush_mode(FlushMode::Manual); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xC7); + + // Buffer a brand-new wallet (state lives only in the buffer). + persister.store(w, full_changeset(21)).unwrap(); + assert!( + persister.buffer_has_changeset_for_test(&w), + "precondition: changeset is buffered" + ); + + // Drop the table the pre-flush apply will INSERT into. Use a side + // connection so the persister's own conn is untouched until delete. + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute("DROP TABLE core_sync_state", []).unwrap(); + } + + // delete_wallet drains the buffer, opens the pre-flush EXCLUSIVE tx, + // and applies the changeset — the core-state INSERT now fails with a + // real "no such table" SQL error. + let err = persister.delete_wallet_skip_backup(w); + assert!( + err.is_err(), + "delete must fail when the pre-flush apply hits a real SQL error; got {err:?}" + ); + + // The buffered changeset MUST survive the failed delete — the + // apply-branch restore put it back. + assert!( + persister.buffer_has_changeset_for_test(&w), + "buffered changeset must be restored after a real pre-flush apply failure" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs index 7f9f18eb71b..64eb8c0a627 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs @@ -76,7 +76,7 @@ fn concurrent_store_does_not_resurrect_deleted_wallet() { // test; here we only guard against a racing store resurrecting the // wallet after the delete commit. let conn = persister.lock_conn_for_test(); - for table in ["wallet_metadata", "core_sync_state"] { + for table in ["wallets", "core_sync_state"] { let n: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM {table} WHERE wallet_id = ?1"), diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet_constraint_carveout.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet_constraint_carveout.rs new file mode 100644 index 00000000000..6f37640d277 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet_constraint_carveout.rs @@ -0,0 +1,117 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Boundary of `delete_wallet`'s pre-flush constraint carve-out. +//! +//! The carve-out exists for one state: pending identity writes that can +//! never be persisted must not make a wallet undeletable. It names the +//! two variants that describe that state +//! (`IdentityIndexConflict` / `WalletlessIdentityIndex`). Every OTHER +//! constraint failure in the drained pre-flush — a native SQLite FK, +//! CHECK, UNIQUE or NOT NULL violation — is a corruption signal that +//! must abort the delete with the buffer intact, not be swallowed at the +//! one moment an operator is removing state. + +mod common; + +use common::{ensure_identity, ensure_wallet_meta, fresh_persister_with_mode}; + +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, PersistenceErrorKind, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet_storage::{FlushMode, WalletStorageError}; +use rusqlite::{params, OptionalExtension}; + +/// A buffered `identity_keys` upsert whose `identity_id` has no +/// `identities` row is an FK violation — nothing to do with +/// `(wallet_id, identity_index)` uniqueness, whether it surfaces raw or +/// wrapped in `IdentityKeyWalletMismatch`. It is `Constraint`-KIND all +/// the same, so a kind-scoped carve-out would swallow it; the delete +/// must instead fail loudly and keep the pending write. +#[test] +fn delete_wallet_aborts_on_a_constraint_failure_outside_the_carve_out() { + let (p, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = common::wid(0x5B); + ensure_wallet_meta(&p, &w); + + // Deliberately NOT seeded into `identities` — the FK target is + // missing. + let identity_id = Identifier::from([0xAB; 32]); + 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: 1, + public_key, + public_key_hash: [9u8; 20], + wallet_id: Some(w), + derivation_indices: None, + }; + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((identity_id, 1), entry); + p.store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .expect("Manual mode only buffers — no FK check happens here"); + + let err = p + .delete_wallet_skip_backup(w) + .expect_err("an FK violation is not the state the carve-out covers"); + + assert!( + !matches!( + err, + WalletStorageError::IdentityIndexConflict { .. } + | WalletStorageError::WalletlessIdentityIndex { .. } + ), + "the carve-out names two variants and this FK violation is neither, got `{err:?}`" + ); + assert_eq!( + err.persistence_kind(), + PersistenceErrorKind::Constraint, + "the kind is Constraint — which is exactly why kind-scoped tolerance was too wide" + ); + + let wallets: i64 = { + let conn = p.lock_conn_for_test(); + conn.query_row( + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", + params![w.as_slice()], + |row| row.get(0), + ) + .expect("count wallets") + }; + assert_eq!(wallets, 1, "the delete aborted — the wallet is still here"); + + // The pending write was restored, not dropped: give the FK its + // target and the same buffered changeset flushes cleanly. + ensure_identity(&p, &[0xAB; 32], Some(&w)); + p.flush(w) + .expect("the restored changeset is still flushable"); + let conn = p.lock_conn_for_test(); + let key_row: Option = conn + .query_row( + "SELECT 1 FROM identity_keys WHERE identity_id = ?1", + params![identity_id.as_slice()], + |row| row.get(0), + ) + .optional() + .expect("query identity_keys"); + assert_eq!(key_row, Some(1), "the buffered write survived the abort"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_deleted_wallet_residue.rs b/packages/rs-platform-wallet-storage/tests/sqlite_deleted_wallet_residue.rs new file mode 100644 index 00000000000..79fb8f17519 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_deleted_wallet_residue.rs @@ -0,0 +1,178 @@ +//! A deleted wallet's rows must not stay legible in the database file. +//! +//! SQLite moves freed pages to the freelist without overwriting them unless +//! `secure_delete` says otherwise, so the addresses, scripts, keys and contact +//! data of a deleted wallet survive in the `.db` — and `Backup` is a page-level +//! copier, so every snapshot taken afterwards carries them forward. +//! +//! The seeded rows are deliberately large enough to span whole pages that the +//! cascade releases outright. That is the case `secure_delete = FAST` does NOT +//! cover: FAST zeroes freed content only within a page it is already +//! rewriting. A test sized to fit inside one shared page would pass under FAST +//! and prove nothing about the guarantee `delete_wallet` claims. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use std::path::{Path, PathBuf}; + +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::SqlitePersister; + +/// Payload bytes per seeded row. At 512 B and 200 rows the wallet's metadata +/// occupies ~100 KiB — tens of 4 KiB pages, freed as whole pages by the +/// cascade rather than emptied in place. +const ROW_PAYLOAD_LEN: usize = 512; +const ROW_COUNT: usize = 200; + +const DOOMED_MARKER: &str = "RESIDUE-PROBE-DOOMED-WALLET-PAYLOAD"; +const SURVIVOR_MARKER: &str = "RESIDUE-PROBE-SURVIVING-WALLET-PAYLOAD"; + +/// Fill `wallet`'s `meta_wallet` rows with a recognisable, page-spanning +/// payload. Written straight through the connection: this test cares about the +/// bytes on disk, not about the changeset path that produced them. +fn seed_recognisable_rows(persister: &SqlitePersister, wallet: &WalletId, marker: &str) { + let payload: Vec = marker + .bytes() + .cycle() + .take(ROW_PAYLOAD_LEN) + .collect::>(); + let conn = persister.lock_conn_for_test(); + for i in 0..ROW_COUNT { + conn.execute( + "INSERT INTO meta_wallet (wallet_id, key, value) VALUES (?1, ?2, ?3)", + rusqlite::params![wallet.as_slice(), format!("residue-probe-{i}"), payload], + ) + .expect("seed meta_wallet row"); + } +} + +/// Pages sitting on the database's freelist — whole pages the cascade released +/// rather than merely emptied in place. +fn pages_on_freelist(persister: &SqlitePersister) -> i64 { + persister + .lock_conn_for_test() + .query_row("PRAGMA freelist_count", [], |row| row.get(0)) + .expect("read freelist_count") +} + +/// Every byte the database occupies: the main file AND its write-ahead log. +/// +/// Recently written pages live in the `-wal` until a checkpoint moves them, so +/// a scan of the `.db` alone would miss data that is plainly still there — and +/// after the delete it would miss residue parked in a WAL that outlived the +/// handle. Both directions matter, so both files are read. +fn database_bytes(path: &Path) -> Vec { + let mut bytes = std::fs::read(path).expect("read the database file"); + let mut wal_name = path.as_os_str().to_os_string(); + wal_name.push("-wal"); + if let Ok(wal) = std::fs::read(PathBuf::from(wal_name)) { + bytes.extend_from_slice(&wal); + } + bytes +} + +fn occurrences(haystack: &[u8], needle: &str) -> usize { + let needle = needle.as_bytes(); + haystack + .windows(needle.len()) + .filter(|window| *window == needle) + .count() +} + +#[test] +fn deleted_wallet_rows_are_not_legible_in_the_database_file() { + let (persister, _tmp, path) = fresh_persister(); + let doomed = wid(0xD1); + let survivor = wid(0x5A); + + ensure_wallet_meta(&persister, &doomed); + ensure_wallet_meta(&persister, &survivor); + seed_recognisable_rows(&persister, &doomed, DOOMED_MARKER); + seed_recognisable_rows(&persister, &survivor, SURVIVOR_MARKER); + + // Pre-delete sanity: the scanner must be able to see the doomed wallet's + // payload while it is still there. A scratch run of this experiment once + // reported zero hits in every mode because `grep` refused to read a binary + // file — a clean bill of health manufactured entirely by a broken scanner. + // Without this line, "absent after the delete" would also be what a scan + // that never matches anything reports. + let before = database_bytes(&path); + assert!( + occurrences(&before, DOOMED_MARKER) > 0, + "the scan cannot see the doomed wallet's rows even before the delete — \ + it is measuring nothing" + ); + assert_eq!( + pages_on_freelist(&persister), + 0, + "fixture expects the seeded pages to be in use before the delete" + ); + + persister + .delete_wallet_skip_backup(doomed) + .expect("delete the doomed wallet"); + + // The freed-page case is the whole point: `secure_delete = FAST` clears + // only the part of a page it is already rewriting, so a fixture small + // enough to sit inside one shared page would pass under FAST and prove + // nothing about what `delete_wallet` claims. + let freed = pages_on_freelist(&persister); + assert!( + freed > 0, + "the cascade released no whole page, so this run does not exercise the \ + case FAST cannot cover" + ); + // Closing the persister checkpoints the WAL into the main database, so the + // scan below reads the pages the delete actually left behind. + drop(persister); + + let bytes = database_bytes(&path); + + // Positive control. Without it, "the marker is absent" is also what a + // broken scan, an empty file, or a mis-seeded fixture would report. + assert!( + occurrences(&bytes, SURVIVOR_MARKER) > 0, + "the scan must be able to find rows that are still present — it found \ + none, so its verdict on the deleted wallet means nothing" + ); + + assert_eq!( + occurrences(&bytes, DOOMED_MARKER), + 0, + "the deleted wallet's row content is still readable in the database \ + file; every backup taken from here carries it forward" + ); +} + +/// A `secure_delete` mode set outside a transaction is still in force inside +/// one on the same connection. +/// +/// `delete_wallet` raises the mode and then runs the cascade in an EXCLUSIVE +/// transaction. If the setting did not survive into that context the delete +/// would report success and leave the pages legible, and nothing would error — +/// the failure mode is silence. `delete_wallet_inner` reads the mode back from +/// inside its own transaction for that reason; this pins the SQLite behaviour +/// that read-back depends on, so a change in it surfaces here rather than as +/// unexplained residue. +#[test] +fn a_secure_delete_mode_survives_into_a_transaction_on_the_same_connection() { + let (persister, _tmp, _path) = fresh_persister(); + let mut conn = persister.lock_conn_for_test(); + + let outside: i64 = conn + .query_row("PRAGMA secure_delete", [], |row| row.get(0)) + .expect("read the steady-state mode"); + assert_eq!(outside, 2, "the persister opens at secure_delete = FAST"); + + conn.pragma_update(None, "secure_delete", "ON") + .expect("raise to the erasing mode"); + let tx = conn.transaction().expect("begin"); + let inside: i64 = tx + .query_row("PRAGMA secure_delete", [], |row| row.get(0)) + .expect("read the mode from inside the transaction"); + assert_eq!( + inside, 1, + "the erasing mode set before the transaction must be in force inside it" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 12415933f2d..36d46cb6073 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -1,23 +1,21 @@ #![allow(clippy::field_reassign_with_default)] -//! `WalletStorageError::is_transient` + `error_kind_str` exhaustiveness -//! check via a wildcard-free `match`, plus the boundary mapping of -//! `FlushRetryable` into `PersistenceError::Backend`. +//! `WalletStorageError::is_transient` + `error_kind_str` exhaustiveness, +//! plus the boundary mapping of `FlushRetryable` into +//! `PersistenceError::Backend`. //! -//! The check 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. +//! The check is a wildcard-free `match` with one arm per variant (no +//! `_`), so a new `WalletStorageError` variant fails to compile here +//! until it is classified — mirroring the matches in `error::is_transient` +//! / `error::error_kind_str`. use std::path::PathBuf; +use dashcore::hashes::Hash; 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::InsecureAncestor; use platform_wallet_storage::WalletStorageError; use rusqlite::{Error as SqlErr, ErrorCode}; @@ -81,6 +79,62 @@ fn sqlite_oom() -> WalletStorageError { )) } +/// A blocked write is only actionable if the message names the way out of +/// recovery mode; both stores refuse writes and both must say it. +#[test] +fn read_only_recovery_display_names_the_way_out() { + let persister = WalletStorageError::ReadOnlyRecoveryMode { operation: "store" }.to_string(); + assert!(persister.contains("`store`"), "{persister}"); + assert!(persister.contains("strict load policy"), "{persister}"); + + let kv = + platform_wallet_storage::KvError::ReadOnlyRecoveryMode { operation: "put" }.to_string(); + assert!(kv.contains("`put`"), "{kv}"); + assert!(kv.contains("strict load policy"), "{kv}"); +} + +/// A derivation failure is read by whoever has to rescue the wallet, so +/// each variant's `Display` must state the consequence — an address that +/// can be handed out again — and not just the step that failed. +#[test] +fn rehydration_derivation_display_states_the_consequence() { + let targeted = WalletStorageError::RehydrationEnsureDerivedFailed { index: 45 }.to_string(); + assert!(targeted.contains("index 45"), "{targeted}"); + assert!(targeted.contains("fresh receive address"), "{targeted}"); + + let over_cap = WalletStorageError::RehydrationGapLimitRefillTooLarge { + refill_target: 300_000, + already_generated: 21, + implied: 299_980, + cap: 250_000, + } + .to_string(); + assert!(over_cap.contains("299980"), "{over_cap}"); + assert!(over_cap.contains("250000"), "{over_cap}"); + assert!(over_cap.contains("handed out again as fresh"), "{over_cap}"); + + let refused = WalletStorageError::RehydrationGapLimitFailed { + source: key_wallet::error::Error::WatchOnly, + } + .to_string(); + assert!(refused.contains("fresh receive address"), "{refused}"); +} + +#[test] +fn core_transaction_mismatch_display_uses_domain_height_labels() { + let confirmed = WalletStorageError::CoreTransactionEntryMismatch { + typed_txid: "11".repeat(32), + blob_txid: "22".repeat(32), + typed_height: Some(200), + blob_height: None, + } + .to_string(); + assert!(confirmed.contains("typed height=200")); + assert!(confirmed.contains("blob height=unconfirmed")); + assert!(!confirmed.contains("Some(")); + assert!(!confirmed.contains("None")); +} + /// One representative sample per `WalletStorageError` variant. /// /// The samples are passed through a wildcard-free `match` below; the @@ -96,15 +150,9 @@ fn samples() -> Vec { 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. + // Migration wraps a refinery error with no public constructor, so + // it can't be synthesised here. It's omitted from the samples but + // the `Migration(_)` arm below still keeps the match exhaustive. WalletStorageError::IntegrityCheckFailed { report: "rows missing".into(), }, @@ -126,6 +174,10 @@ fn samples() -> Vec { dir: PathBuf::from("/nope"), source: std::io::Error::other("nope"), }, + WalletStorageError::InsecureParentDir { + ancestor: PathBuf::from("/opt/dash"), + reason: InsecureAncestor::WritableWithoutSticky { mode: 0o777 }, + }, WalletStorageError::WalletNotFound { wallet_id: [0u8; 32], }, @@ -134,31 +186,94 @@ fn samples() -> Vec { found: [2u8; 32], }, WalletStorageError::IdentityKeyEntryMismatch, + WalletStorageError::IdentityKeyWalletMismatch { + wallet_id: [3u8; 32], + identity_id: [4u8; 32], + source: Box::new(SqlErr::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::ConstraintViolation, + extended_code: 787, + }, + Some("FOREIGN KEY constraint failed".into()), + )), + }, WalletStorageError::AssetLockEntryMismatch { typed_outpoint: "txid:0".into(), blob_outpoint: "txid:1".into(), typed_account_index: 5, blob_account_index: 9, }, + WalletStorageError::AssetLockStatusMismatch { + outpoint: "txid:0".into(), + typed_status: "built".into(), + blob_status: "consumed".into(), + }, + WalletStorageError::CoreTransactionEntryMismatch { + typed_txid: "11".repeat(32), + blob_txid: "22".repeat(32), + typed_height: Some(100), + blob_height: Some(101), + }, WalletStorageError::BlobTooLarge { len_bytes: 32 * 1024 * 1024, limit_bytes: 16 * 1024 * 1024, }, WalletStorageError::ForeignKeysNotEnforced, + WalletStorageError::JournalModeNotApplied { + requested: "WAL", + actual: "delete".into(), + }, + WalletStorageError::SecureDeleteNotApplied { actual: 0 }, + WalletStorageError::SchemaHistoryMalformed { + reason: "bad applied_on", + }, + WalletStorageError::NotAWalletDb { + expected: 0x504C_5754, + found: 0, + }, + WalletStorageError::AlreadyOpen { + path: PathBuf::from("/x/w.db"), + }, WalletStorageError::LockPoisoned, WalletStorageError::RestoreDestinationLocked, WalletStorageError::InvalidWalletIdHex { source: hex::FromHexError::OddLength, }, - WalletStorageError::InvalidWalletIdLength { actual: 10 }, + WalletStorageError::InvalidWalletIdLength { + column: "wallets.wallet_id", + actual: 10, + }, WalletStorageError::ConfigInvalid { reason: "bad knob" }, WalletStorageError::IdentityEntryIdMismatch, - WalletStorageError::UtxoAddressNotDerived { - address: "yMockAddress".into(), + WalletStorageError::IdentityIndexConflict { + wallet_id: [3u8; 32], + identity_index: 1, + existing: [4u8; 32], + incoming: [5u8; 32], + }, + WalletStorageError::WalletlessIdentityIndex { + identity_id: [6u8; 32], + identity_index: 2, + }, + WalletStorageError::OrphanedIdentityEntry { owner: [0x0E; 32] }, + WalletStorageError::WalletRehydrationFailed { + wallet_id: [0x0F; 32], + cause: "address decode failed".to_string(), + }, + WalletStorageError::AddressDecode { + source: dashcore::address::Error::UnrecognizedScript, + }, + WalletStorageError::AccountRegistrationEntryMismatch, + WalletStorageError::ProviderKeyAccountEntryMismatch, + WalletStorageError::ProviderKeyAccountConflict { + account_type: "provider_platform", + }, + WalletStorageError::TypedPoolKeyConflict { + account_type: "provider_platform", + address_index: 0, }, // BincodeEncode / BincodeDecode / HashDecode / ConsensusCodec - // need real upstream errors — synthesise minimal ones via the - // public constructors / `From` impls. + // need real upstream errors; omitted but covered by their arms. WalletStorageError::BlobDecode { reason: "bad shape", }, @@ -170,6 +285,24 @@ fn samples() -> Vec { value: u64::MAX, target: SafeCastTarget::U64, }, + WalletStorageError::MissingAccount { + wallet_id: [3u8; 32], + }, + WalletStorageError::AccountRecordInvalid { + e: key_wallet::error::Error::WatchOnly, + }, + WalletStorageError::AccountRejected { + cause: "unknown account_type".into(), + }, + WalletStorageError::RehydrationPoolMismatch { + expected: 2, + found: 1, + }, + WalletStorageError::RehydrationPoolTypeMismatch { + position: 0, + expected: key_wallet::managed_account::address_pool::AddressPoolType::External, + found: key_wallet::managed_account::address_pool::AddressPoolType::Internal, + }, WalletStorageError::FlushRetryable { wallet_id: [0xAB; 32], source: SqlErr::SqliteFailure( @@ -180,16 +313,53 @@ fn samples() -> Vec { Some("busy".into()), ), }, + WalletStorageError::ReadOnlyRecoveryMode { operation: "store" }, + WalletStorageError::RehydrationEnsureDerivedFailed { index: 45 }, + WalletStorageError::RehydrationGapLimitRefillTooLarge { + refill_target: 300_000, + already_generated: 20, + implied: 299_980, + cap: 250_000, + }, + WalletStorageError::RehydrationGapLimitFailed { + source: key_wallet::error::Error::WatchOnly, + }, + WalletStorageError::UsedAddressOwnerConflict { + address: "yaddr".into(), + pool_owner: "Standard[0]".into(), + utxo_owner: "CoinJoin[0]".into(), + }, + WalletStorageError::UnownedIdentityHasRegistrationIndex { + identity_id: [0xEF; 32], + identity_index: 3, + }, + WalletStorageError::IdentityScanStateContradiction { + wallet_id: [0xFA; 32], + failed_indices: 2, + }, + WalletStorageError::EmptyUtxoScript { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0xAB; 32]), + vout: 1, + }, + }, + WalletStorageError::EmptyPoolAddressScript { + account_type: "standard_bip44", + address_index: 7, + }, + WalletStorageError::RehydrationGapLimitTargetOutOfRange { + highest_used: Some(u32::MAX - 5), + gap_limit: 20, + }, + WalletStorageError::DatabasePathIsSymlink { + path: PathBuf::from("/tmp/wallet.db"), + }, ] } -/// 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. +/// Wildcard-free exhaustiveness gate: each variant's expected +/// `(is_transient, error_kind_str)` pair is asserted via a `match` with +/// no `_` arm. #[test] fn tc_p2_005_is_transient_table() { fn classify(err: &WalletStorageError) -> (bool, &'static str) { @@ -225,6 +395,7 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::AutoBackupDirUnwritable { .. } => { (false, "auto_backup_dir_unwritable") } + WalletStorageError::InsecureParentDir { .. } => (false, "insecure_parent_dir"), WalletStorageError::WalletNotFound { .. } => (false, "wallet_not_found"), WalletStorageError::WalletIdMismatch { .. } => (false, "wallet_id_mismatch"), WalletStorageError::LockPoisoned => (false, "lock_poisoned"), @@ -237,18 +408,92 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::BlobDecode { .. } => (false, "blob_decode"), WalletStorageError::HashDecode { .. } => (false, "hash_decode"), WalletStorageError::ConsensusCodec { .. } => (false, "consensus_codec"), + WalletStorageError::AddressDecode { .. } => (false, "address_decode"), WalletStorageError::BackupDestinationExists { .. } => { (false, "backup_destination_exists") } WalletStorageError::IdentityKeyEntryMismatch => (false, "identity_key_entry_mismatch"), + WalletStorageError::IdentityKeyWalletMismatch { .. } => { + (false, "identity_key_wallet_mismatch") + } WalletStorageError::IdentityEntryIdMismatch => (false, "identity_entry_id_mismatch"), + WalletStorageError::IdentityScanStateContradiction { .. } => { + (false, "identity_scan_state_contradiction") + } + WalletStorageError::IdentityIndexConflict { .. } => (false, "identity_index_conflict"), + WalletStorageError::WalletlessIdentityIndex { .. } => { + (false, "walletless_identity_index") + } + WalletStorageError::OrphanedIdentityEntry { .. } => (false, "orphaned_identity_entry"), + WalletStorageError::WalletRehydrationFailed { .. } => { + (false, "wallet_rehydration_failed") + } WalletStorageError::AssetLockEntryMismatch { .. } => { (false, "asset_lock_entry_mismatch") } + WalletStorageError::AssetLockStatusMismatch { .. } => { + (false, "asset_lock_status_mismatch") + } + WalletStorageError::CoreTransactionEntryMismatch { .. } => { + (false, "core_transaction_entry_mismatch") + } WalletStorageError::BlobTooLarge { .. } => (false, "blob_too_large"), - WalletStorageError::UtxoAddressNotDerived { .. } => (false, "utxo_address_not_derived"), WalletStorageError::ForeignKeysNotEnforced => (false, "foreign_keys_not_enforced"), + WalletStorageError::JournalModeNotApplied { .. } => (false, "journal_mode_not_applied"), + WalletStorageError::SecureDeleteNotApplied { .. } => { + (false, "secure_delete_not_applied") + } + WalletStorageError::SchemaHistoryMalformed { .. } => { + (false, "schema_history_malformed") + } + WalletStorageError::NotAWalletDb { .. } => (false, "not_a_wallet_db"), + WalletStorageError::AlreadyOpen { .. } => (false, "already_open"), WalletStorageError::IntegerOverflow { .. } => (false, "integer_overflow"), + WalletStorageError::AccountRegistrationEntryMismatch => { + (false, "account_registration_entry_mismatch") + } + WalletStorageError::ProviderKeyAccountEntryMismatch => { + (false, "provider_key_account_entry_mismatch") + } + WalletStorageError::ProviderKeyAccountConflict { .. } => { + (false, "provider_key_account_conflict") + } + WalletStorageError::TypedPoolKeyConflict { .. } => (false, "typed_pool_key_conflict"), + WalletStorageError::MissingAccount { .. } => { + (false, "missing_account_registration_entry") + } + WalletStorageError::AccountRecordInvalid { .. } => (false, "account_record_invalid"), + WalletStorageError::AccountRejected { .. } => (false, "account_rejected"), + WalletStorageError::RehydrationPoolMismatch { .. } => { + (false, "rehydration_pool_mismatch") + } + WalletStorageError::RehydrationPoolTypeMismatch { .. } => { + (false, "rehydration_pool_type_mismatch") + } + WalletStorageError::ReadOnlyRecoveryMode { .. } => (false, "read_only_recovery_mode"), + WalletStorageError::RehydrationEnsureDerivedFailed { .. } => { + (false, "rehydration_ensure_derived_failed") + } + WalletStorageError::RehydrationGapLimitRefillTooLarge { .. } => { + (false, "rehydration_gap_limit_refill_too_large") + } + WalletStorageError::RehydrationGapLimitTargetOutOfRange { .. } => { + (false, "rehydration_gap_limit_target_out_of_range") + } + WalletStorageError::RehydrationGapLimitFailed { .. } => { + (false, "rehydration_gap_limit_failed") + } + WalletStorageError::UsedAddressOwnerConflict { .. } => { + (false, "used_address_owner_conflict") + } + WalletStorageError::UnownedIdentityHasRegistrationIndex { .. } => { + (false, "unowned_identity_has_registration_index") + } + WalletStorageError::EmptyUtxoScript { .. } => (false, "empty_utxo_script"), + WalletStorageError::EmptyPoolAddressScript { .. } => { + (false, "empty_pool_address_script") + } + WalletStorageError::DatabasePathIsSymlink { .. } => (false, "database_path_is_symlink"), } } @@ -305,9 +550,9 @@ fn tc_p2_010_boundary_error_mapping() { "missing wallet_id hex prefix: {outer}" ); - // Walk the typed source chain to the inner rusqlite payload — - // post- the source is `Box` so - // the chain is preserved structurally, not just stringified. + // Walk the typed source chain to the inner rusqlite payload: 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 { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs b/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs new file mode 100644 index 00000000000..518efb2fd7b --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs @@ -0,0 +1,283 @@ +#![allow(clippy::field_reassign_with_default)] + +//! FK parent-before-child ordering inside a single immediate-FK +//! transaction, exercised through the production `store()` -> flush path. +//! Two contracts hold: +//! +//! 1. A child whose FK parent is neither in the same payload nor on disk +//! aborts the flush with a `Constraint`-kind `PersistenceError` and +//! wipes the buffer (non-transient => no retry): the caller must +//! include the parent in the same `store()` or write it first. +//! 2. A changeset carrying parent and child together commits — the fixed +//! dispatch order writes the parent first for every FK edge. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, wid}; + +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::{ + IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, + PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::identity::IdentityStatus; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::identity_keys; +use platform_wallet_storage::FlushMode; + +fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: identity, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + } +} + +fn identity_entry(id: Identifier, wallet_id: Option) -> IdentityEntry { + IdentityEntry { + id, + balance: 0, + revision: 0, + identity_index: Some(0), + 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, + dashpay_profile: None, + dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } +} + +fn keys_changeset(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeysChangeSet { + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts + .insert((identity, key_id), key_entry(identity, key_id, byte)); + keys +} + +fn identities_changeset(id: Identifier, wallet_id: Option) -> IdentityChangeSet { + let mut identities = std::collections::BTreeMap::new(); + identities.insert(id, identity_entry(id, wallet_id)); + IdentityChangeSet { + identities, + removed: Default::default(), + } +} + +/// A changeset carrying `identity_keys` for an identity whose +/// `identities` parent is absent from both the payload and the DB (the +/// `wallets` parent is present, isolating the failure) aborts the flush +/// with a `Constraint`-kind `PersistenceError` carrying the constraint +/// class — not a panic or a raw-string-only error. +#[test] +fn identity_keys_without_parent_identity_aborts_with_constraint_kind() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA1); + ensure_wallet_meta(&persister, &w); // wallet parent present; identity parent absent + let orphan_identity = Identifier::from([0x33; 32]); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys_changeset(orphan_identity, 0, 0x11)), + ..Default::default() + }, + ) + .expect_err("child-without-parent flush must fail, not silently succeed"); + + assert_eq!( + err.kind(), + Some(PersistenceErrorKind::Constraint), + "an immediate-FK abort must surface as a Constraint-kind PersistenceError, got {err:?}" + ); + // The underlying rusqlite source must be walkable to the real FK + // violation — the typed wrapper preserves it rather than flattening + // to a lossy string. + let source_chain = { + use std::error::Error; + let mut s = String::new(); + let mut cur: Option<&dyn Error> = Some(&err); + while let Some(e) = cur { + s.push_str(&e.to_string()); + s.push('\n'); + cur = e.source(); + } + s + }; + assert!( + source_chain.contains("FOREIGN KEY"), + "the FK violation must be reachable via Error::source(), got chain:\n{source_chain}" + ); +} + +/// The constraint abort wipes the buffer: a follow-up `flush()` is a +/// clean no-op and nothing reached disk for the orphaned identity. +#[test] +fn constraint_abort_wipes_buffer_no_silent_retry() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA2); + ensure_wallet_meta(&persister, &w); + let orphan_identity = Identifier::from([0x44; 32]); + + let _ = persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys_changeset(orphan_identity, 0, 0x55)), + ..Default::default() + }, + ) + .expect_err("must fail"); + + // Buffer wiped: the next flush finds nothing to write and is a no-op. + PlatformWalletPersistence::flush(&persister, w).expect("post-abort flush is a clean no-op"); + + // And nothing was committed for the orphan identity. + let on_disk = identity_keys::load_state( + &persister.lock_conn_for_test(), + &w, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("load identity_keys"); + assert!( + on_disk.upserts.is_empty(), + "no identity_keys row may have been committed for the orphaned identity" + ); +} + +/// The same contract in Manual flush mode: `store` only buffers, the +/// abort surfaces from the explicit `flush`, and the buffer is wiped so +/// the failed write is dropped (not silently re-attempted forever). +#[test] +fn manual_mode_child_without_parent_aborts_on_flush_and_drops_buffer() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xA3); + ensure_wallet_meta(&persister, &w); + let orphan_identity = Identifier::from([0x66; 32]); + + // Manual mode: store buffers without touching SQL. + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys_changeset(orphan_identity, 0, 0x77)), + ..Default::default() + }, + ) + .expect("manual-mode store only buffers"); + + let err = PlatformWalletPersistence::flush(&persister, w) + .expect_err("explicit flush must surface the FK abort"); + assert_eq!( + err.kind(), + Some(PersistenceErrorKind::Constraint), + "manual-mode flush abort must also be Constraint-kind, got {err:?}" + ); + + // Buffer wiped on the fatal classification: a second flush is a no-op. + PlatformWalletPersistence::flush(&persister, w).expect("second flush is a clean no-op"); +} + +/// The recovery contract: a COMPLETE changeset carrying the `identities` +/// parent AND its `identity_keys` child in the SAME `store()` commits. +/// This proves the fixed dispatch order writes `identities` before +/// `identity_keys` so the immediate FK is satisfied at the child insert +/// — the parent-before-child invariant for the `identity_id` FK edge. +#[test] +fn parent_and_child_in_same_changeset_commits() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xB1); + ensure_wallet_meta(&persister, &w); + let identity = Identifier::from([0x88; 32]); + + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(identities_changeset(identity, Some(w))), + identity_keys: Some(keys_changeset(identity, 0, 0x99)), + ..Default::default() + }, + ) + .expect("parent+child in one changeset must commit under the fixed dispatch order"); + + let on_disk = identity_keys::load_state( + &persister.lock_conn_for_test(), + &w, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("load identity_keys"); + assert_eq!( + on_disk.upserts.len(), + 1, + "the child identity_keys row must be committed alongside its parent identity" + ); + assert!( + on_disk.upserts.contains_key(&(identity, 0)), + "the committed row must be the one we wrote" + ); +} + +/// The same edge from the wallets side: a complete changeset that carries +/// the `wallets` root anchor (via `wallet_metadata`) AND a `wallet_id`-FK +/// child (`identity_keys`, also needing its identity parent) commits in +/// one flush. `wallets` is dispatched first, so the child's +/// `wallet_id -> wallets` FK is satisfied even when the wallets row did +/// not pre-exist. +#[test] +fn wallets_anchor_and_children_in_same_changeset_commits() { + use key_wallet::Network; + use platform_wallet::changeset::WalletMetadataEntry; + + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xB2); // deliberately NOT pre-seeded — the changeset carries it + let identity = Identifier::from([0xAB; 32]); + + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 0, + }), + identities: Some(identities_changeset(identity, Some(w))), + identity_keys: Some(keys_changeset(identity, 0, 0xCD)), + ..Default::default() + }, + ) + .expect("wallets anchor + children in one changeset must commit"); + + let on_disk = identity_keys::load_state( + &persister.lock_conn_for_test(), + &w, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("load identity_keys"); + assert_eq!( + on_disk.upserts.len(), + 1, + "the wallet_id-FK child must commit because wallets is dispatched first" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_db_rejection.rs b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_db_rejection.rs new file mode 100644 index 00000000000..d419be02fa7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_db_rejection.rs @@ -0,0 +1,31 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `open()` must reject a pre-existing NON-wallet SQLite file (schema objects +//! but no `refinery_schema_history`) instead of silently grafting wallet +//! tables onto a foreign schema. + +mod common; + +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +#[test] +fn open_rejects_foreign_sqlite_without_refinery_history() { + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("foreign.db"); + + // A plain SQLite DB with a user table but no refinery history and no + // wallet application_id. + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute("CREATE TABLE not_ours (id INTEGER PRIMARY KEY)", []) + .unwrap(); + } + + // `SqlitePersister` isn't `Debug`, so take `.err()` rather than + // `.expect_err()` (which would need the Ok type to be `Debug`). + let err = SqlitePersister::open(SqlitePersisterConfig::new(&path)).err(); + assert!( + matches!(err, Some(WalletStorageError::NotAWalletDb { .. })), + "a foreign sqlite db must be rejected as NotAWalletDb, got {err:?}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs index e97a87c3be7..15a750ce886 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs @@ -1,10 +1,10 @@ #![allow(clippy::field_reassign_with_default)] -//! Native foreign-key enforcement and the delete cascade. +//! TC-045..TC-049 — native foreign-key enforcement and the delete cascade. mod common; -use common::{ensure_wallet_meta, fresh_persister, wid}; +use common::{ensure_identity, ensure_wallet_meta, fresh_persister, wid}; /// PRAGMA foreign_keys is ON on the connection. #[test] @@ -17,7 +17,7 @@ fn tc045_foreign_keys_on() { assert_eq!(fk, 1, "foreign_keys pragma not ON"); } -/// insert into a child table without a wallet_metadata parent fails. +/// insert into a child table without a wallets parent fails. #[test] fn tc046_orphan_child_insert_rejected() { let (persister, _tmp, _path) = fresh_persister(); @@ -35,7 +35,7 @@ fn tc046_orphan_child_insert_rejected() { ); } -/// deleting wallet_metadata cascades. +/// deleting wallets cascades. #[test] fn tc047_delete_wallet_cascade() { let (persister, _tmp, _path) = fresh_persister(); @@ -65,58 +65,50 @@ fn tc047_delete_wallet_cascade() { assert_eq!(n, 0); } -/// deleting a core_transactions row sets `spent_in_txid = NULL` on UTXOs. +/// TC-049: `identity_keys` rows carry TWO `ON DELETE CASCADE` parents +/// (`wallet_id -> wallets`, `(wallet_id, identity_id) -> identities`). +/// Deleting the wallet must purge the child via that dual-cascade — both +/// paths firing on one row is idempotent, not a double-free error. #[test] -fn tc048_setnull_on_tx_delete() { +fn tc049_delete_wallet_cascades_identity_keys() { let (persister, _tmp, _path) = fresh_persister(); - let w = wid(0xC2); + let w = wid(0xC4); + let identity = [0xE4u8; 32]; + // Seed BOTH FK parents: the wallets row and a wallet-scoped + // identities row, so the child satisfies both cascade chains. 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(); + ensure_identity(&persister, &identity, Some(&w)); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, X'01', ?3, NULL)", + rusqlite::params![w.as_slice(), &identity[..], &[0u8; 20][..]], + ) + .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 + let before: i64 = persister + .lock_conn_for_test() .query_row( - "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", - rusqlite::params![w.as_slice(), &outpoint], + "SELECT COUNT(*) FROM identity_keys WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], |row| row.get(0), ) .unwrap(); - assert_eq!(count, 1, "UTXO row must survive the transaction delete"); + assert_eq!(before, 1, "seed row must exist before delete"); - let (wallet_id, value, account_index, spent_in): (Vec, i64, i64, Option>) = conn + let report = persister.delete_wallet(w).expect("delete_wallet"); + assert_eq!(report.wallet_id, w); + + let after: i64 = persister + .lock_conn_for_test() .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)?)), + "SELECT COUNT(*) FROM identity_keys WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), ) .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" - ); + assert_eq!(after, 0, "dual cascade must purge the identity_keys row"); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_identity_index_concurrency.rs b/packages/rs-platform-wallet-storage/tests/sqlite_identity_index_concurrency.rs new file mode 100644 index 00000000000..ecf3b29fa85 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_identity_index_concurrency.rs @@ -0,0 +1,213 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `store()` never reports `Ok` for an identity it did not persist, +//! however two threads interleave. +//! +//! The slot check and the buffer merge share one critical section: the +//! check runs inside `Buffer::store_checked`, under the buffer lock, +//! with the connection held, and against the merged (buffered plus +//! incoming) view. Two threads racing the same wallet + same slot are +//! therefore serialized — the loser sees the winner as the occupant and +//! is refused at store time, before its changeset can join a +//! contradictory merge that a later flush would drop whole while the +//! caller walks away holding an `Ok(())`. +//! +//! `flush_inner` takes the connection BEFORE draining the buffer and +//! holds it through the write, which closes the matching window on the +//! other side: mid-flush the changeset is in neither the buffer nor the +//! database, and a probe that ran there would read a slot as free that +//! is not. +//! +//! The proof is the deterministic test below: it parks the first +//! `store()` mid-window and releases the second claim into that exact +//! window, so a regression fails every run. The stress loop after it is +//! a shaker, not the proof — it exists because the original defect's +//! window was a handful of instructions wide (Marvin reproduced it once +//! in ~500-2000 jammer-assisted attempts) and it reaches interleavings +//! no scripted rendezvous names. Its iteration count is a CI-time +//! trade, not a confidence threshold: every attempt builds a database +//! and spins six threads. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, release_at_store_seam, wid}; + +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::identity::IdentityStatus; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::SqlitePersister; +use rusqlite::{params, OptionalExtension}; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Barrier}; +use std::thread; +use std::time::Duration; + +/// Attempts the stress loop makes. High enough to interleave two +/// threads many different ways, low enough that the deterministic test +/// above stays the one carrying the guarantee. +const STRESS_ATTEMPTS: usize = 64; + +/// How long the parked `store()` waits for the second claim. It always +/// expires — the second claim is parked on the write connection — so +/// keep it short. +const CLAIM_BUDGET: Duration = Duration::from_millis(250); + +fn iid(byte: u8) -> Identifier { + Identifier::from([byte; 32]) +} + +fn identity_entry(id: u8, index: u32) -> IdentityEntry { + IdentityEntry { + id: iid(id), + balance: u64::from(id), + revision: 1, + identity_index: Some(index), + 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(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } +} + +fn identity_cs(entry: IdentityEntry) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: [(entry.id, entry)].into_iter().collect(), + removed: Default::default(), + }), + ..Default::default() + } +} + +fn live_occupant(p: &SqlitePersister, wallet_id: &WalletId, index: u32) -> Option<[u8; 32]> { + let conn = p.lock_conn_for_test(); + conn.query_row( + "SELECT identity_id FROM identities \ + WHERE wallet_id IS ?1 AND identity_index = ?2 AND tombstoned = 0", + params![wallet_id.as_slice(), i64::from(index)], + |row| row.get::<_, Vec>(0), + ) + .optional() + .expect("query live occupant") + .map(|raw| raw.try_into().expect("32-byte identity_id")) +} + +/// A second claim on the slot, released into the exact window where the +/// first `store()` has merged but not yet flushed, must be refused — +/// there is no interleaving in which both succeed, because the first +/// call holds the write connection across the whole window and the +/// second needs it to probe the slot. +#[test] +fn a_second_claim_released_mid_store_is_refused() { + let (p, _tmp, _path) = fresh_persister(); + let p = Arc::new(p); + let w = wid(0xAB); + ensure_wallet_meta(&p, &w); + + let second_claimant = Arc::clone(&p); + let claimant = release_at_store_seam(&p, CLAIM_BUDGET, move || { + second_claimant.store(w, identity_cs(identity_entry(0x02, 1))) + }); + p.store(w, identity_cs(identity_entry(0x01, 1))) + .expect("the first claim is uncontested"); + let second = claimant.join().expect("claimant panicked"); + + assert!( + second.is_err(), + "the slot was taken by the time the second claim was judged: {second:?}" + ); + assert_eq!( + live_occupant(&p, &w, 1), + Some([0x01; 32]), + "the winner's identity is the one on disk" + ); +} + +/// Exactly one of two racing claims on a slot may succeed, and the +/// identity it named must be on disk when `store()` returns `Ok` in +/// `FlushMode::Immediate`. Any single attempt that breaks either half is +/// a confirmed regression; the loop just keeps shaking the interleaving. +#[test] +fn racing_stores_never_report_ok_for_an_identity_that_was_dropped() { + for attempt in 0..STRESS_ATTEMPTS { + let (p, _tmp, _path) = fresh_persister(); + let p = Arc::new(p); + let w = wid(0xAA); + ensure_wallet_meta(&p, &w); + let barrier = Arc::new(Barrier::new(2)); + + // Jammer threads: hammer the SAME connection mutex `store()`'s + // probe locks internally (exposed test-only via + // `lock_conn_for_test`) to inject scheduling noise around the + // probe/buffer-merge boundary — widening the otherwise tiny + // TOCTOU window enough to observe it deterministically. + let jam_run = Arc::new(AtomicBool::new(true)); + let jammers: Vec<_> = (0..4) + .map(|_| { + let jp = Arc::clone(&p); + let jr = Arc::clone(&jam_run); + thread::spawn(move || { + while jr.load(Ordering::Relaxed) { + let g = jp.lock_conn_for_test(); + drop(g); + thread::yield_now(); + } + }) + }) + .collect(); + + let (p1, b1) = (Arc::clone(&p), Arc::clone(&barrier)); + let t1 = thread::spawn(move || { + b1.wait(); + p1.store(w, identity_cs(identity_entry(0x01, 1))) + }); + let (p2, b2) = (Arc::clone(&p), Arc::clone(&barrier)); + let t2 = thread::spawn(move || { + b2.wait(); + p2.store(w, identity_cs(identity_entry(0x02, 1))) + }); + + let r1 = t1.join().expect("thread 1 panicked"); + let r2 = t2.join().expect("thread 2 panicked"); + jam_run.store(false, Ordering::Relaxed); + for j in jammers { + j.join().expect("jammer panicked"); + } + + if r1.is_ok() && r2.is_ok() { + panic!( + "attempt {attempt}: both concurrent stores reported Ok(()) — two \ + identities cannot both legitimately hold index 1" + ); + } + + let occupant = live_occupant(&p, &w, 1); + let ok_wants_01 = r1.is_ok(); + let ok_wants_02 = r2.is_ok(); + let lost_ok = (ok_wants_01 && occupant != Some([0x01; 32])) + || (ok_wants_02 && occupant != Some([0x02; 32])); + + if lost_ok { + panic!( + "attempt {attempt}: store() returned Ok(()) for an identity that never \ + reached disk — r1={r1:?} r2={r2:?} occupant={occupant:?}" + ); + } + if r1.is_err() && r2.is_err() { + panic!( + "attempt {attempt}: the slot was free and uncontested by anyone else — \ + one of the two claims had to win: r1={r1:?} r2={r2:?}" + ); + } + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_identity_index_uniqueness.rs b/packages/rs-platform-wallet-storage/tests/sqlite_identity_index_uniqueness.rs new file mode 100644 index 00000000000..8b113b45475 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_identity_index_uniqueness.rs @@ -0,0 +1,691 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Write-path enforcement of `(wallet_id, identity_index)` uniqueness. +//! +//! `identity_index` is an HD derivation-path component, so one wallet +//! slot names exactly one identity. A duplicate that reaches disk leaves +//! the displaced identity's keys and contacts without an owner, and the +//! next `load()` rejects the WHOLE wallet's state as fatal — so the +//! offending write is refused instead, attributed to the caller that +//! made it. + +mod common; + +use common::{ + ensure_identity, ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, wid, +}; + +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + IdentityChangeSet, IdentityEntry, PersistenceError, PersistenceErrorKind, + PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::identity::IdentityStatus; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::{ + FlushMode, SqlitePersister, SqlitePersisterConfig, WalletStorageError, +}; +use rusqlite::{params, OptionalExtension}; + +/// Sentinel wallet scope — "no parent wallet known", stored as a NULL +/// `identities.wallet_id`. +const SENTINEL: WalletId = [0u8; 32]; + +fn iid(byte: u8) -> Identifier { + Identifier::from([byte; 32]) +} + +fn identity_entry(id: u8, index: Option) -> IdentityEntry { + IdentityEntry { + id: iid(id), + balance: u64::from(id), + revision: 1, + identity_index: index, + 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(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } +} + +fn identity_cs(entries: E, removed: R) -> PlatformWalletChangeSet +where + E: IntoIterator, + R: IntoIterator, +{ + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: entries.into_iter().map(|e| (e.id, e)).collect(), + removed: removed.into_iter().collect(), + }), + ..Default::default() + } +} + +/// Borrow the typed backend error out of a `PersistenceError`. +fn typed(err: &PersistenceError) -> &WalletStorageError { + match err { + PersistenceError::Backend { source, .. } => source + .downcast_ref::() + .expect("backend source is a WalletStorageError"), + other => panic!("unexpected persistence error: {other}"), + } +} + +fn backend_kind(err: &PersistenceError) -> PersistenceErrorKind { + match err { + PersistenceError::Backend { kind, .. } => *kind, + other => panic!("unexpected persistence error: {other}"), + } +} + +/// Live (non-tombstoned) occupant of `(wallet_id, index)`, if any. +fn live_occupant(p: &SqlitePersister, wallet_id: &WalletId, index: u32) -> Option<[u8; 32]> { + let conn = p.lock_conn_for_test(); + let wid_param: Option<&[u8]> = if *wallet_id == SENTINEL { + None + } else { + Some(wallet_id.as_slice()) + }; + conn.query_row( + "SELECT identity_id FROM identities \ + WHERE wallet_id IS ?1 AND identity_index = ?2 AND tombstoned = 0", + params![wid_param, i64::from(index)], + |row| row.get::<_, Vec>(0), + ) + .optional() + .expect("query live occupant") + .map(|raw| raw.try_into().expect("32-byte identity_id")) +} + +/// `Some(tombstoned)` when the row exists at all. +fn row_tombstoned(p: &SqlitePersister, id: &Identifier) -> Option { + let conn = p.lock_conn_for_test(); + conn.query_row( + "SELECT tombstoned FROM identities WHERE identity_id = ?1", + params![id.as_slice()], + |row| row.get::<_, i64>(0), + ) + .optional() + .expect("query identity row") + .map(|t| t != 0) +} + +/// Claim a slot by writing an `identities` row straight to the DB — +/// stands in for a cross-process peer (a sibling `SqlitePersister` on +/// the same file) taking the slot after a changeset was already +/// buffered against a free one. That is the only way disk state moves +/// under a buffered changeset now that in-process stores check the +/// buffer too. +fn peer_claims_slot(p: &SqlitePersister, wallet_id: &WalletId, id: &Identifier, index: u32) { + let conn = p.lock_conn_for_test(); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, ?3, X'00', 0)", + params![id.as_slice(), wallet_id.as_slice(), i64::from(index)], + ) + .expect("peer claims slot"); +} + +fn assert_index_conflict(err: &PersistenceError) { + let typed = typed(err); + assert!( + matches!(typed, WalletStorageError::IdentityIndexConflict { .. }), + "expected IdentityIndexConflict, got `{typed:?}`" + ); + assert!( + !typed.is_transient(), + "a duplicate index is a caller bug, never retryable" + ); + assert_eq!( + typed.persistence_kind(), + PersistenceErrorKind::Constraint, + "duplicate index is an integrity violation, not a Fatal engine failure" + ); + assert_eq!(backend_kind(err), PersistenceErrorKind::Constraint); +} + +/// A second identity claiming a live slot is refused at write time, and +/// neither the occupant nor the intruder's absence is negotiable. +#[test] +fn duplicate_index_is_rejected_at_write_time() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("first identity at index 1"); + + let err = p + .store(w, identity_cs([identity_entry(0x02, Some(1))], [])) + .expect_err("a second identity at index 1 must be refused"); + + assert_index_conflict(&err); + assert_eq!( + live_occupant(&p, &w, 1), + Some([0x01; 32]), + "the resident identity must keep its slot" + ); + assert_eq!( + row_tombstoned(&p, &iid(0x02)), + None, + "the rejected identity must not reach disk at all" + ); +} + +/// Rejecting one wallet's write leaves another wallet's data alone, and +/// the same index in two wallets is legal — slots are scoped per wallet. +#[test] +fn rejection_is_scoped_to_the_offending_wallet() { + let (p, _tmp, _path) = fresh_persister(); + let a = wid(0xA1); + let b = wid(0xB1); + ensure_wallet_meta(&p, &a); + ensure_wallet_meta(&p, &b); + p.store(a, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("wallet a, index 1"); + p.store(b, identity_cs([identity_entry(0x11, Some(1))], [])) + .expect("wallet b may reuse index 1 — slots are per-wallet"); + + let err = p + .store(a, identity_cs([identity_entry(0x02, Some(1))], [])) + .expect_err("wallet a's slot 1 is taken"); + + assert_index_conflict(&err); + assert_eq!( + live_occupant(&p, &b, 1), + Some([0x11; 32]), + "the untouched wallet keeps its identity" + ); +} + +/// The rejection happens BEFORE the shared buffer is touched, so a +/// changeset already staged for the same wallet still flushes. Rejecting +/// inside the flush would drop it as collateral. +#[test] +fn rejected_duplicate_does_not_swallow_a_staged_changeset() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xD1); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("resident identity at index 1"); + + // A transient flush failure restores the changeset to the shared + // buffer — the same place an interleaved `store()` would sit. + p.force_next_flush_to_fail(WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::DatabaseBusy, + extended_code: rusqlite::ffi::SQLITE_BUSY, + }, + Some("database is busy".into()), + ))); + p.store(w, identity_cs([identity_entry(0x03, Some(2))], [])) + .expect_err("primed transient failure"); + + let err = p + .store(w, identity_cs([identity_entry(0x02, Some(1))], [])) + .expect_err("duplicate index must be refused"); + assert_index_conflict(&err); + + p.flush(w).expect("staged changeset still flushes"); + assert_eq!( + live_occupant(&p, &w, 2), + Some([0x03; 32]), + "the staged write survived the rejection" + ); + assert_eq!(row_tombstoned(&p, &iid(0x02)), None); +} + +/// The probe keys on the FLUSH SCOPE, not on the incoming identity's own +/// stored `wallet_id`. Identity `0x02` sits unparented (NULL wallet_id); +/// writing it under scope `w` would promote it into `w`'s slot 1, which +/// `0x01` already holds. Looking in the row's own (NULL) bucket would +/// miss the occupant entirely. +#[test] +fn probe_keys_on_the_flush_scope_not_the_stored_wallet_id() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xE1); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("resident identity at index 1"); + ensure_identity(&p, &[0x02; 32], None); + + let err = p + .store(w, identity_cs([identity_entry(0x02, Some(1))], [])) + .expect_err("promotion into a taken slot must be refused"); + + assert_index_conflict(&err); + assert_eq!(live_occupant(&p, &w, 1), Some([0x01; 32])); +} + +/// A tombstoned row holds no slot: remove-then-re-register at the same +/// index is legitimate reuse, not a collision. +#[test] +fn tombstoned_row_frees_its_slot_for_reuse() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xF1); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("first identity at index 1"); + p.store(w, identity_cs([], [iid(0x01)])) + .expect("remove the first identity"); + + p.store(w, identity_cs([identity_entry(0x02, Some(1))], [])) + .expect("the freed slot must be reusable"); + + assert_eq!(row_tombstoned(&p, &iid(0x01)), Some(true)); + assert_eq!(live_occupant(&p, &w, 1), Some([0x02; 32])); +} + +/// Removal and re-registration merged into ONE changeset has a legal +/// final state (the tombstone lands in the same transaction), so the +/// probe must treat a removed id as holding no slot. +#[test] +fn tombstone_and_reinsert_in_one_changeset_is_accepted() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xF2); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("first identity at index 1"); + + p.store(w, identity_cs([identity_entry(0x02, Some(1))], [iid(0x01)])) + .expect("tombstone + reinsert at the same index is legal"); + + assert_eq!(row_tombstoned(&p, &iid(0x01)), Some(true)); + assert_eq!(live_occupant(&p, &w, 1), Some([0x02; 32])); +} + +/// Two identities claiming one slot inside a SINGLE changeset are both +/// refused — no winner is picked without evidence. +#[test] +fn colliding_entries_in_one_changeset_are_rejected_without_picking_a_winner() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xF3); + ensure_wallet_meta(&p, &w); + + let err = p + .store( + w, + identity_cs( + [identity_entry(0x01, Some(1)), identity_entry(0x02, Some(1))], + [], + ), + ) + .expect_err("two identities cannot share one slot"); + + assert_index_conflict(&err); + assert_eq!(row_tombstoned(&p, &iid(0x01)), None); + assert_eq!(row_tombstoned(&p, &iid(0x02)), None); + assert_eq!(live_occupant(&p, &w, 1), None); +} + +/// Distinct indices in one changeset are ordinary traffic. +#[test] +fn distinct_indices_in_one_changeset_are_accepted() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xF4); + ensure_wallet_meta(&p, &w); + + p.store( + w, + identity_cs( + [identity_entry(0x01, Some(0)), identity_entry(0x02, Some(1))], + [], + ), + ) + .expect("two identities at two indices"); + + assert_eq!(live_occupant(&p, &w, 0), Some([0x01; 32])); + assert_eq!(live_occupant(&p, &w, 1), Some([0x02; 32])); +} + +/// Re-writing the SAME identity at its own index is an update, not a +/// collision. +#[test] +fn reupserting_the_same_identity_at_its_own_index_is_accepted() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xF5); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("initial write"); + + let mut updated = identity_entry(0x01, Some(1)); + updated.balance = 42; + p.store(w, identity_cs([updated], [])) + .expect("updating an identity in place is not a duplicate"); + + assert_eq!(live_occupant(&p, &w, 1), Some([0x01; 32])); +} + +/// An occupant that the SAME changeset moves to another index has +/// vacated its old slot by the time the changeset lands, exactly like +/// one it tombstones. Judging the final state, not the starting one, is +/// what makes the guard a uniqueness rule rather than a freeze. +#[test] +fn an_occupant_reindexed_in_the_same_changeset_frees_its_old_slot() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xF7); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("initial write"); + + p.store( + w, + identity_cs( + [identity_entry(0x01, Some(2)), identity_entry(0x02, Some(1))], + [], + ), + ) + .expect("A moves to 2 and B takes 1 — the final state is unique"); + + assert_eq!(live_occupant(&p, &w, 1), Some([0x02; 32])); + assert_eq!(live_occupant(&p, &w, 2), Some([0x01; 32])); +} + +/// A two-way swap: both identities block each other's target slot on +/// disk, and both vacate it in the same changeset. +#[test] +fn two_identities_swapping_indices_in_one_changeset_are_accepted() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xF8); + ensure_wallet_meta(&p, &w); + p.store( + w, + identity_cs( + [identity_entry(0x01, Some(1)), identity_entry(0x02, Some(2))], + [], + ), + ) + .expect("initial write"); + + p.store( + w, + identity_cs( + [identity_entry(0x01, Some(2)), identity_entry(0x02, Some(1))], + [], + ), + ) + .expect("a swap ends with one identity per slot"); + + assert_eq!(live_occupant(&p, &w, 1), Some([0x02; 32])); + assert_eq!(live_occupant(&p, &w, 2), Some([0x01; 32])); +} + +/// Dropping an occupant's index entirely (it becomes an out-of-wallet +/// identity) frees the slot on the same terms — the row survives, the +/// claim does not. +#[test] +fn an_occupant_losing_its_index_in_the_same_changeset_frees_its_slot() { + let (p, _tmp, _path) = fresh_persister(); + let w = wid(0xF9); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("initial write"); + + p.store( + w, + identity_cs( + [identity_entry(0x01, None), identity_entry(0x02, Some(1))], + [], + ), + ) + .expect("A gives up its index in the same changeset that B claims it"); + + assert_eq!(live_occupant(&p, &w, 1), Some([0x02; 32])); + assert_eq!( + row_tombstoned(&p, &iid(0x01)), + Some(false), + "A is still a live row, just no longer in a wallet slot" + ); +} + +/// Wallet-less identities carry NO index (`out_of_wallet_identities` is +/// keyed by identity id alone), so an indexed write under the sentinel +/// scope is refused. +#[test] +fn walletless_identity_carrying_an_index_is_rejected() { + let (p, _tmp, _path) = fresh_persister(); + + let err = p + .store(SENTINEL, identity_cs([identity_entry(0x01, Some(3))], [])) + .expect_err("a wallet-less identity has no derivation index"); + + let typed = typed(&err); + assert!( + matches!(typed, WalletStorageError::WalletlessIdentityIndex { .. }), + "expected WalletlessIdentityIndex, got `{typed:?}`" + ); + assert!(!typed.is_transient()); + assert_eq!(typed.persistence_kind(), PersistenceErrorKind::Constraint); + assert_eq!(row_tombstoned(&p, &iid(0x01)), None); +} + +/// The companion positive case: a wallet-less identity WITHOUT an index +/// is first-class and still stores. +#[test] +fn walletless_identity_without_an_index_is_accepted() { + let (p, _tmp, _path) = fresh_persister(); + + p.store(SENTINEL, identity_cs([identity_entry(0x01, None)], [])) + .expect("wallet-less identities are first-class"); + + assert_eq!(row_tombstoned(&p, &iid(0x01)), Some(false)); +} + +/// A store checks disk and buffer as they are at that instant. A +/// cross-process peer can claim the slot afterwards, so the flush +/// re-runs the check against what is actually about to land — +/// otherwise manual mode writes the duplicate the store-time check +/// exists to prevent. +#[test] +fn flush_rejects_a_duplicate_that_a_peer_created_under_the_buffer() { + let (p, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xB7); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x02, Some(1))], [])) + .expect("valid against a clean disk"); + peer_claims_slot(&p, &w, &iid(0x01), 1); + + let err = p.flush(w).expect_err("the slot is no longer free"); + + assert_index_conflict(&err); + assert_eq!( + live_occupant(&p, &w, 1), + Some([0x01; 32]), + "the whole transaction rolls back — the peer's row stands" + ); + assert_eq!(row_tombstoned(&p, &iid(0x02)), None); + + // A fatal flush failure drops that wallet's buffer rather than + // restoring an unflushable changeset, so the retry is a no-op + // instead of the same failure forever. + p.flush(w).expect("the poisoned changeset is not retried"); + assert_eq!(live_occupant(&p, &w, 1), Some([0x01; 32])); +} + +/// The slot check runs against the buffer as well as the disk, and both +/// run inside the buffer's own critical section. A second claimant is +/// therefore refused at store time — while the error can still be +/// attributed to the caller that caused it — instead of merging into a +/// contradictory changeset the flush later drops whole. +#[test] +fn a_slot_held_by_a_buffered_write_refuses_a_second_claimant() { + let (p, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xB8); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("first claim on a free slot"); + + let err = p + .store(w, identity_cs([identity_entry(0x02, Some(1))], [])) + .expect_err("the slot is held by a buffered write"); + + assert_index_conflict(&err); + p.flush(w) + .expect("the buffered changeset is still flushable"); + assert_eq!( + live_occupant(&p, &w, 1), + Some([0x01; 32]), + "the first caller's write is untouched by the rejection" + ); + assert_eq!( + row_tombstoned(&p, &iid(0x02)), + None, + "the rejected changeset never reached the buffer" + ); +} + +/// A buffered removal frees the slot it names: the check reads the +/// merged view exactly as the flush will apply it, and `apply` inserts +/// before it tombstones. Reclaiming the slot in a later store is +/// legitimate reuse, not a collision. +#[test] +fn a_buffered_removal_frees_its_slot_for_a_later_store() { + let (p, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xB9); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("first claim on a free slot"); + + p.store(w, identity_cs([identity_entry(0x02, Some(1))], [iid(0x01)])) + .expect("the occupant is removed by the same buffered changeset"); + + p.flush(w).expect("the merged changeset is consistent"); + assert_eq!(live_occupant(&p, &w, 1), Some([0x02; 32])); + assert_eq!(row_tombstoned(&p, &iid(0x01)), Some(true)); +} + +/// One wallet's rejected flush is one wallet's problem: `commit_writes` +/// gives every dirty wallet its own transaction, so a neighbour's +/// legitimate writes land in the same pass. +#[test] +fn flush_rejection_leaves_other_wallets_untouched() { + let (p, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let poisoned = wid(0xC7); + let healthy = wid(0xD7); + ensure_wallet_meta(&p, &poisoned); + ensure_wallet_meta(&p, &healthy); + p.store(poisoned, identity_cs([identity_entry(0x02, Some(1))], [])) + .expect("store"); + peer_claims_slot(&p, &poisoned, &iid(0x01), 1); + p.store(healthy, identity_cs([identity_entry(0x11, Some(1))], [])) + .expect("store"); + + let report = p.commit_writes().expect("commit_writes enumerates fine"); + + assert_eq!( + report.succeeded, + vec![healthy], + "the healthy wallet commits" + ); + assert_eq!(report.failed.len(), 1, "exactly one wallet fails"); + assert_eq!(report.failed[0].0, poisoned); + assert_index_conflict(&report.failed[0].1); + assert!(report.still_pending.is_empty()); + assert_eq!( + live_occupant(&p, &healthy, 1), + Some([0x11; 32]), + "the neighbour's write is not collateral" + ); + assert_eq!(live_occupant(&p, &poisoned, 1), Some([0x01; 32])); +} + +/// Pending writes that can never be persisted must not make a wallet +/// undeletable — deleting it is the remedy for exactly that state. The +/// pre-delete flush drops them and the cascade proceeds. +#[test] +fn delete_wallet_proceeds_despite_unpersistable_pending_writes() { + let (p, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0x5A); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("store"); + peer_claims_slot(&p, &w, &iid(0x02), 1); + + let report = p + .delete_wallet_skip_backup(w) + .expect("an unflushable buffer must not block the delete"); + + assert_eq!(report.wallet_id, w); + assert_eq!(row_tombstoned(&p, &iid(0x01)), None); + assert_eq!( + row_tombstoned(&p, &iid(0x02)), + None, + "the peer's row went with the wallet's cascade" + ); + let conn = p.lock_conn_for_test(); + let wallets: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", + params![w.as_slice()], + |row| row.get(0), + ) + .expect("count wallets"); + assert_eq!(wallets, 0, "the wallet is gone"); +} + +/// Dropping those pending writes is only justified once the wallet is +/// actually gone. The carve-out fires long before the cascade commits — +/// the auto-backup, the `BEGIN EXCLUSIVE`, the cascade and its commit +/// can all still fail — and if one does, the wallet is still here, so +/// its staged writes must be too. They may bundle sub-changesets that +/// have nothing to do with the offending identity entry. +#[test] +fn a_delete_that_fails_after_the_carve_out_keeps_the_pending_writes() { + let tmp = common::secure_tempdir().expect("tempdir"); + let path = tmp.path().join("wallet.db"); + // No auto-backup directory: `delete_wallet` then fails in + // `run_auto_backup`, the step right after the carve-out. + let p = SqlitePersister::open( + SqlitePersisterConfig::new(&path) + .with_flush_mode(FlushMode::Manual) + .with_auto_backup_dir(None), + ) + .expect("open persister"); + let w = wid(0x5C); + ensure_wallet_meta(&p, &w); + p.store(w, identity_cs([identity_entry(0x01, Some(1))], [])) + .expect("store"); + peer_claims_slot(&p, &w, &iid(0x02), 1); + + let err = p + .delete_wallet(w) + .expect_err("no auto-backup directory is configured"); + assert!( + matches!(err, WalletStorageError::AutoBackupDisabled { .. }), + "the delete must fail at the backup, after the carve-out: `{err:?}`" + ); + + let wallets: i64 = { + let conn = p.lock_conn_for_test(); + conn.query_row( + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", + params![w.as_slice()], + |row| row.get(0), + ) + .expect("count wallets") + }; + assert_eq!(wallets, 1, "the delete aborted — the wallet is still here"); + + // The staged write is not merely present, it is intact: clear what + // made it unflushable and it lands exactly as it was staged. + { + let conn = p.lock_conn_for_test(); + conn.execute( + "DELETE FROM identities WHERE identity_id = ?1", + params![iid(0x02).as_slice()], + ) + .expect("drop the peer's row"); + } + p.flush(w).expect("the staged changeset is still flushable"); + assert_eq!( + live_occupant(&p, &w, 1), + Some([0x01; 32]), + "the carve-out dropped a changeset for a wallet that still exists" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs new file mode 100644 index 00000000000..b2340a392b0 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs @@ -0,0 +1,152 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `schema::identity_keys::load_state` reads `identity_keys` rows back +//! into a keyless `IdentityKeysChangeSet`, bit-exact, fail-hard on a +//! corrupt blob. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +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, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::identity_keys; +use platform_wallet_storage::WalletStorageError; + +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 key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: identity, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: u32::from(byte), + }), + } +} + +/// Identity-key rows round-trip bit-exact into the keyless +/// `IdentityKeysChangeSet`. +#[test] +fn gk1_identity_keys_roundtrip() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD1); + ensure_wallet_meta(&persister, &w); + let id_a = Identifier::from([0x0A; 32]); + let id_b = Identifier::from([0x0B; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id_a.as_slice().try_into().unwrap(), + ) + .unwrap(); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id_b.as_slice().try_into().unwrap(), + ) + .unwrap(); + + let e1 = key_entry(id_a, 0, 0x11); + let e2 = key_entry(id_a, 1, 0x22); + let e3 = key_entry(id_b, 0, 0x33); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((id_a, 0), e1.clone()); + keys.upserts.insert((id_a, 1), e2.clone()); + keys.upserts.insert((id_b, 0), e3.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + drop(conn); + + assert_eq!(cs.upserts.len(), 3); + assert_eq!(cs.upserts.get(&(id_a, 0)), Some(&e1)); + assert_eq!(cs.upserts.get(&(id_a, 1)), Some(&e2)); + assert_eq!(cs.upserts.get(&(id_b, 0)), Some(&e3)); + assert!(cs.removed.is_empty()); +} + +/// An empty wallet yields an empty changeset, not an error. +#[test] +fn gk2_empty_identity_keys_is_ok() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD2); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + drop(conn); + assert!(cs.upserts.is_empty()); +} + +/// A corrupt `public_key_blob` is a typed hard error, never a silent +/// skip. +#[test] +fn gk3_corrupt_blob_is_hard_error() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD3); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x0C; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, X'00', ?3, NULL)", + rusqlite::params![w.as_slice(), id.as_slice(), &[0u8; 20][..]], + ) + .unwrap(); + } + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let result = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()); + drop(conn); + assert!( + matches!(result, Err(WalletStorageError::BincodeDecode { .. })), + "corrupt public_key_blob must be a typed BincodeDecode; got {result:?}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_identity_scan_state.rs b/packages/rs-platform-wallet-storage/tests/sqlite_identity_scan_state.rs new file mode 100644 index 00000000000..b0b37c2da5e --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_identity_scan_state.rs @@ -0,0 +1,384 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Cross-launch persistence of the gap-limit identity-scan verdict. +//! +//! The open half of dashpay/platform#4365: a scan that left an index +//! unanswered published `Ok` with the identities it did find, and the fact +//! that it was partial existed nowhere once the process exited. The next +//! launch saw an identity on file, took the warm-launch shortcut, and an +//! identity at the unanswered index stayed invisible for the life of the +//! installation. +//! +//! These tests drive the public [`PlatformWalletPersistence`] surface both +//! ways and assert on +//! [`IdentityManager::identity_scan_is_incomplete`](platform_wallet::wallet::identity::IdentityManager::identity_scan_is_incomplete) +//! — the exact call the startup sequence makes to decide whether it may skip +//! discovery. Asserting on a reader helper instead would prove the row was +//! written and read, never that the decision it exists to change can see it. + +mod common; + +use std::path::Path; + +use common::{ensure_wallet_meta, fresh_persister, store_and_flush, wid}; +use platform_wallet::changeset::{ + IdentityScanStateEntry, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::identity::IdentityManager; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::{LoadPolicy, SqlitePersister, SqlitePersisterConfig}; + +/// Flush a scan verdict for `wallet_id` through the public trait. +fn store_verdict(persister: &SqlitePersister, wallet_id: WalletId, entry: IdentityScanStateEntry) { + let mut cs = PlatformWalletChangeSet::default(); + cs.identity_scan_state = Some(entry); + store_and_flush(persister, wallet_id, cs); +} + +/// Reopen the database at `path` and return what `load()` restored for +/// `wallet_id`, alongside the rebuilt manager the startup sequence queries. +/// +/// The seeding persister must already be dropped — the process-wide open-path +/// registry refuses a second live persister on one path. +fn reload(path: &Path, wallet_id: &WalletId) -> (Option, IdentityManager) { + reload_with_policy(path, wallet_id, LoadPolicy::Strict) +} + +fn reload_with_policy( + path: &Path, + wallet_id: &WalletId, + policy: LoadPolicy, +) -> (Option, IdentityManager) { + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(path).with_load_policy(policy)) + .expect("reopen persister"); + let mut state = persister.load().expect("load"); + let wallet_state = state + .wallets + .remove(wallet_id) + .expect("the seeded wallet must come back from load()"); + let restored = wallet_state + .identity_manager + .scan_states + .get(wallet_id) + .cloned(); + ( + restored, + IdentityManager::from(wallet_state.identity_manager), + ) +} + +/// The regression #4365 names: a scan that could not answer every index must +/// still be known to have been partial after a restart, so the next launch +/// rescans instead of trusting the identities already on file. +#[test] +fn should_restore_an_incomplete_scan_verdict_across_a_reopen() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x41); + ensure_wallet_meta(&persister, &w); + let verdict = IdentityScanStateEntry::incomplete(0, 5, vec![1]); + store_verdict(&persister, w, verdict.clone()); + drop(persister); + + let (restored, manager) = reload(&path, &w); + + assert_eq!( + restored.as_ref(), + Some(&verdict), + "the verdict must survive the reopen byte for byte" + ); + assert!( + manager.identity_scan_is_incomplete(&w), + "a restored partial scan must re-open the identity question — this is the \ + warm-launch shortcut #4365 was hiding behind" + ); +} + +/// The other half of the contract: a scan that answered everything must not +/// cost every later launch a rescan. +#[test] +fn should_restore_a_complete_scan_verdict_without_forcing_a_rescan() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x42); + ensure_wallet_meta(&persister, &w); + let verdict = IdentityScanStateEntry::completed(0, 9); + store_verdict(&persister, w, verdict.clone()); + drop(persister); + + let (restored, manager) = reload(&path, &w); + + assert_eq!(restored.as_ref(), Some(&verdict)); + assert!( + !manager.identity_scan_is_incomplete(&w), + "a clean scan must leave the warm-launch shortcut intact" + ); +} + +/// Absence is not completeness, and it is not incompleteness either: a wallet +/// that never scanned restores no entry at all, which upstream reads as "keep +/// the existing behaviour" rather than "rescan every launch". +#[test] +fn should_leave_the_scan_verdict_absent_for_a_wallet_that_never_scanned() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x43); + ensure_wallet_meta(&persister, &w); + drop(persister); + + let (restored, manager) = reload(&path, &w); + + assert!( + restored.is_none(), + "a wallet with no recorded scan must restore no verdict" + ); + assert!( + !manager.identity_scan_is_incomplete(&w), + "an unknown verdict must not be read as an incomplete one" + ); +} + +/// A suffix scan may not clear a gap it never probed, even when the verdict +/// reaching the persister was not folded first. +/// +/// Discovery resumes one past the highest registered identity, so a wallet +/// with identities at 0 and 2 and no answer at 1 resumes at 3, comes back +/// clean, and publishes `complete`. In-process the manager folds that over the +/// gap; a peer process holding a staler view does not. The writer folds +/// against what is on disk so the durable record can only ever gain a gap, +/// never silently lose one. +#[test] +fn should_carry_forward_a_gap_a_later_suffix_scan_never_probed() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x44); + ensure_wallet_meta(&persister, &w); + store_verdict( + &persister, + w, + IdentityScanStateEntry::incomplete(0, 5, vec![1]), + ); + // Unfolded, exactly as a peer process that never saw the gap would send it. + store_verdict(&persister, w, IdentityScanStateEntry::completed(3, 9)); + drop(persister); + + let (restored, manager) = reload(&path, &w); + let restored = restored.expect("verdict must be present"); + + assert_eq!( + restored.failed_indices, + vec![1], + "index 1 lies below the suffix scan's coverage, so nothing has answered it" + ); + assert!( + !restored.complete, + "a verdict carrying an unanswered index is not complete" + ); + assert!( + manager.identity_scan_is_incomplete(&w), + "the carried gap must still force a rescan" + ); +} + +/// An unlocated gap has no index to name it, so only a clean scan from index 0 +/// may clear it — a suffix scan that comes back clean must not. +#[test] +fn should_carry_forward_an_unlocated_gap_until_a_scan_from_zero_covers_it() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x45); + ensure_wallet_meta(&persister, &w); + // A scan abandoned mid-await: it answered no index and failed none. + store_verdict( + &persister, + w, + IdentityScanStateEntry::incomplete(0, 0, Vec::new()), + ); + store_verdict(&persister, w, IdentityScanStateEntry::completed(3, 9)); + drop(persister); + + let (restored, manager) = reload(&path, &w); + let restored = restored.expect("verdict must be present"); + + assert!( + restored.unlocated_gap, + "a suffix scan covered no more than the window it walked" + ); + assert!(!restored.complete); + assert!(manager.identity_scan_is_incomplete(&w)); + + // A clean scan from the bottom of the index space is the one thing that + // can speak for the region nobody could point at. + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("reopen"); + store_verdict(&persister, w, IdentityScanStateEntry::completed(0, 12)); + drop(persister); + + let (restored, manager) = reload(&path, &w); + let restored = restored.expect("verdict must be present"); + assert!(!restored.unlocated_gap, "a from-zero clean scan clears it"); + assert!(restored.complete); + assert!(!manager.identity_scan_is_incomplete(&w)); +} + +/// Every unanswered index survives, ascending, including one that exercises +/// the full `u32` range across the `i64` storage column. +#[test] +fn should_round_trip_failed_indices_in_ascending_order() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x46); + ensure_wallet_meta(&persister, &w); + let failed = vec![0u32, 3, 17, u32::MAX]; + store_verdict( + &persister, + w, + IdentityScanStateEntry::incomplete(0, u32::MAX, failed.clone()), + ); + drop(persister); + + let (restored, _) = reload(&path, &w); + let restored = restored.expect("verdict must be present"); + + assert_eq!(restored.failed_indices, failed); + assert_eq!(restored.probed_from, 0); + assert_eq!(restored.probed_through, u32::MAX); +} + +/// The verdict is wallet-scoped state, so deleting the wallet must take it — +/// and its child index rows — with it. +#[test] +fn should_drop_the_scan_verdict_when_its_wallet_is_deleted() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0x47); + ensure_wallet_meta(&persister, &w); + store_verdict( + &persister, + w, + IdentityScanStateEntry::incomplete(0, 5, vec![1, 2]), + ); + + persister.delete_wallet(w).expect("delete_wallet"); + + let conn = persister.lock_conn_for_test(); + for table in ["identity_scan_states", "identity_scan_failed_indices"] { + let n: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE wallet_id = ?1"), + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap_or_else(|e| panic!("COUNT(*) failed for `{table}`: {e}")); + assert_eq!(n, 0, "`{table}` must not outlive its wallet"); + } +} + +/// A row claiming a complete scan while unanswered indices sit beside it +/// contradicts itself — `superseding` can never produce one. Under the strict +/// default that is corruption and aborts the load. +#[test] +fn should_reject_a_scan_row_claiming_completeness_over_an_open_gap() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x48); + ensure_wallet_meta(&persister, &w); + store_verdict(&persister, w, IdentityScanStateEntry::completed(0, 9)); + forge_orphan_failed_index(&persister, &w, 4); + drop(persister); + + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("reopen"); + let err = persister + .load() + .expect_err("a self-contradicting verdict row must abort a strict load"); + assert!( + format!("{err}").contains("scan"), + "the error must name the scan verdict, got: {err}" + ); +} + +/// Under recovery the same row is tolerated rather than fatal, and clamped +/// toward incomplete. The asymmetry is deliberate: the cost of being wrong +/// this way is one extra scan, and the cost of being wrong the other way is an +/// identity that never reappears. +#[test] +fn should_downgrade_a_contradictory_scan_row_under_recovery() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x49); + ensure_wallet_meta(&persister, &w); + store_verdict(&persister, w, IdentityScanStateEntry::completed(0, 9)); + forge_orphan_failed_index(&persister, &w, 4); + drop(persister); + + let (restored, manager) = reload_with_policy(&path, &w, LoadPolicy::Recovery); + let restored = restored.expect("recovery keeps the row"); + + assert!( + !restored.complete, + "a contradictory row must be clamped toward rescanning" + ); + assert_eq!(restored.failed_indices, vec![4]); + assert!(manager.identity_scan_is_incomplete(&w)); +} + +/// Write a failed-index row the writer itself would never produce: one sitting +/// beside a verdict that claims completeness. +fn forge_orphan_failed_index(persister: &SqlitePersister, wallet_id: &WalletId, index: u32) { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO identity_scan_failed_indices (wallet_id, failed_index) VALUES (?1, ?2)", + rusqlite::params![wallet_id.as_slice(), i64::from(index)], + ) + .expect("forge contradictory failed-index row"); +} + +/// The upgrade path: a database standing at the previous release schema gains +/// both tables, empty, and its existing wallet rows are left alone. +#[test] +fn should_create_the_scan_verdict_tables_when_upgrading_from_v016() { + use platform_wallet_storage::sqlite::migrations as mig; + use rusqlite::params; + + let mut conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); + conn.pragma_update(None, "foreign_keys", true) + .expect("enable foreign keys"); + + let to_v016 = mig::runner().set_target(refinery::Target::Version(16)); + to_v016.run(&mut conn).expect("migrate to V016"); + + let w = [0x4Au8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 7)", + params![w.as_slice()], + ) + .expect("insert wallet"); + + for table in ["identity_scan_states", "identity_scan_failed_indices"] { + assert!( + !table_exists(&conn, table), + "`{table}` must not exist before V017" + ); + } + + mig::run(&mut conn).expect("apply V017"); + + for table in ["identity_scan_states", "identity_scan_failed_indices"] { + assert!(table_exists(&conn, table), "V017 must create `{table}`"); + let n: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("count rows"); + assert_eq!(n, 0, "`{table}` starts empty — V017 backfills nothing"); + } + + let birth_height: i64 = conn + .query_row( + "SELECT birth_height FROM wallets WHERE wallet_id = ?1", + params![w.as_slice()], + |row| row.get(0), + ) + .expect("pre-existing wallet row survives the upgrade"); + assert_eq!(birth_height, 7); +} + +fn table_exists(conn: &rusqlite::Connection, name: &str) -> bool { + conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + [name], + |_| Ok(()), + ) + .is_ok() +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_legacy_state_migration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_legacy_state_migration.rs new file mode 100644 index 00000000000..6e282494a7a --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_legacy_state_migration.rs @@ -0,0 +1,597 @@ +//! Populated pre-rehydration databases must retain their typed state on upgrade. + +mod common; + +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::{AddressPoolType, AddressState}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Network}; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, AccountRegistrationEntry, PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::{migrations, schema::blob}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use rusqlite::{params, Connection}; + +const WALLET_ID: [u8; 32] = [0x61; 32]; + +type StoredPoolRow = (String, u32, Vec, bool, Option>, Option); + +fn standard_type() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } +} + +fn legacy_database( + path: &std::path::Path, +) -> (Connection, AccountRegistrationEntry, Vec) { + let mut conn = Connection::open(path).unwrap(); + conn.pragma_update(None, "foreign_keys", true).unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(7)) + .run(&mut conn) + .unwrap(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![WALLET_ID.as_slice()], + ) + .unwrap(); + let wallet = Wallet::from_seed_bytes( + [0x61; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let entry = AccountRegistrationEntry { + account_type: standard_type(), + account_xpub: wallet + .accounts + .account_of_type(standard_type()) + .unwrap() + .account_xpub, + }; + insert_registration(&conn, &WALLET_ID, "standard", 0, &entry); + let managed = ManagedWalletInfo::from_wallet(&wallet, 0); + let mut addresses = managed + .all_managed_accounts() + .into_iter() + .find(|account| account.managed_account_type().to_account_type() == standard_type()) + .unwrap() + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::External) + .unwrap() + .addresses + .values() + .cloned() + .collect::>(); + addresses.sort_by_key(|info| info.index); + (conn, entry, addresses) +} + +fn insert_registration( + conn: &Connection, + wallet_id: &[u8; 32], + label: &str, + index: u32, + entry: &AccountRegistrationEntry, +) { + conn.execute( + "INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, ?2, ?3, ?4)", + params![wallet_id.as_slice(), label, index, blob::encode(entry).unwrap()], + ).unwrap(); +} + +fn snapshot_bytes(entry: &AccountAddressPoolEntry) -> Vec { + // The base backend encoded this public-only type with bincode-serde. + bincode::serde::encode_to_vec(entry, bincode::config::standard()).unwrap() +} + +fn insert_pool(conn: &Connection, addresses: Vec) -> Vec { + let bytes = snapshot_bytes(&AccountAddressPoolEntry { + account_type: standard_type(), + pool_type: AddressPoolType::External, + addresses, + }); + conn.execute( + "INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard', 0, 'external', ?2)", + params![WALLET_ID.as_slice(), &bytes], + ).unwrap(); + bytes +} + +fn history(conn: &Connection) -> Vec<(i64, String, String, String)> { + conn.prepare( + "SELECT version, name, applied_on, checksum FROM refinery_schema_history ORDER BY version", + ) + .unwrap() + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .unwrap() + .collect::>() + .unwrap() +} + +#[test] +fn should_backfill_legacy_account_discriminators_and_load_strictly() { + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (conn, _, _) = legacy_database(&path); + let mut wallet = Wallet::from_seed_bytes( + [0x61; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let contact_type = AccountType::DashpayReceivingFunds { + index: 4, + user_identity_id: [0x21; 32], + friend_identity_id: [0x22; 32], + }; + let platform_type = AccountType::PlatformPayment { + account: 3, + key_class: 1, + }; + wallet.add_account(contact_type, None).unwrap(); + wallet.add_account(platform_type, None).unwrap(); + let contact = AccountRegistrationEntry { + account_type: contact_type, + account_xpub: wallet + .accounts + .account_of_type(contact_type) + .unwrap() + .account_xpub, + }; + let platform = AccountRegistrationEntry { + account_type: platform_type, + account_xpub: wallet + .accounts + .account_of_type(platform_type) + .unwrap() + .account_xpub, + }; + insert_registration(&conn, &WALLET_ID, "dashpay_receiving", 4, &contact); + insert_registration(&conn, &WALLET_ID, "platform_payment", 3, &platform); + let original_history = history(&conn); + drop(conn); + + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + let (user, friend): (Vec, Vec) = conn.query_row( + "SELECT user_identity_id, friend_identity_id FROM account_registrations WHERE account_type = 'dashpay_receiving'", + [], |row| Ok((row.get(0)?, row.get(1)?)), + ).unwrap(); + assert_eq!( + user, [0x21; 32], + "migration must recover the actual contact owner" + ); + assert_eq!(friend, [0x22; 32]); + let class: u32 = conn + .query_row( + "SELECT key_class FROM account_registrations WHERE account_type = 'platform_payment'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(class, 1); + assert_eq!(&history(&conn)[..7], original_history); + drop(conn); + assert!(persister.load().unwrap().wallets.contains_key(&WALLET_ID)); +} + +#[test] +fn should_preserve_legacy_used_reserved_public_keys_and_derived_only_rows() { + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (conn, _, mut addresses) = legacy_database(&path); + addresses[0].state = AddressState::Used; + addresses[1].state = AddressState::Reserved { at: 1_700_000_000 }; + insert_pool(&conn, addresses[..2].to_vec()); + conn.execute( + "INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, address, derivation_path, used) VALUES (?1, 'standard', 0, ?2, 'external/2', 1)", + params![WALLET_ID.as_slice(), addresses[2].address.to_string()], + ).unwrap(); + drop(conn); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + { + let conn = persister.lock_conn_for_test(); + let count: u32 = conn + .query_row("SELECT count(*) FROM core_address_pool", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!( + count, 3, + "used snapshots and derived-only rows must survive" + ); + for index in 0u32..3 { + let (label, account, script, used, key, reserved): StoredPoolRow = conn.query_row( + "SELECT account_type, account_index, script, used, public_key, reserved_at FROM core_address_pool WHERE wallet_id = ?1 AND address_index = ?2", + params![WALLET_ID.as_slice(), index], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)), + ).unwrap(); + assert_eq!(label, "standard_bip44"); + assert_eq!(account, 0); + assert_eq!(script, addresses[index as usize].script_pubkey.to_bytes()); + assert_eq!(used, index != 1); + if index < 2 { + let key_wallet::managed_account::address_pool::PublicKeyType::ECDSA(expected) = + addresses[index as usize].public_key.as_ref().unwrap() + else { + panic!("ECDSA fixture") + }; + assert_eq!(key.as_ref(), Some(expected)); + } + assert_eq!(reserved, (index == 1).then_some(1_700_000_000)); + } + } + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = reopened.lock_conn_for_test(); + let count: u32 = conn + .query_row("SELECT count(*) FROM core_address_pool", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 3, "reopening must be idempotent"); +} + +#[test] +fn should_leave_legacy_data_and_history_unchanged_on_malformed_pool() { + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (conn, _, addresses) = legacy_database(&path); + let good_blob = insert_pool(&conn, addresses[..2].to_vec()); + conn.execute("UPDATE account_address_pools SET snapshot_blob = X'00'", []) + .unwrap(); + let original_history = history(&conn); + drop(conn); + assert!( + SqlitePersister::open(SqlitePersisterConfig::new(&path)).is_err(), + "a malformed legacy pool must not be silently discarded" + ); + let conn = Connection::open(&path).unwrap(); + assert_eq!(history(&conn), original_history); + let stored: Vec = conn + .query_row( + "SELECT snapshot_blob FROM account_address_pools", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, [0]); + let backups = std::fs::read_dir(dir.path().join("backups/auto")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert_eq!(backups.len(), 1); + let backup = Connection::open(&backups[0]).unwrap(); + assert_eq!(history(&backup), original_history); + assert_eq!( + backup + .query_row( + "SELECT snapshot_blob FROM account_address_pools", + [], + |row| row.get::<_, Vec>(0) + ) + .unwrap(), + [0] + ); + conn.execute( + "UPDATE account_address_pools SET snapshot_blob = ?1", + params![good_blob], + ) + .unwrap(); + drop(conn); + // The failed attempt's backup remains intact. Use a separate destination + // for the repaired retry, which may occur within the same timestamp second. + let repaired = SqlitePersister::open( + SqlitePersisterConfig::new(&path) + .with_auto_backup_dir(Some(dir.path().join("retry-backups"))), + ) + .unwrap(); + let conn = repaired.lock_conn_for_test(); + let count: u32 = conn + .query_row("SELECT count(*) FROM core_address_pool", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 2); +} + +#[test] +fn should_resume_intermediate_targets_after_reopen_without_losing_staged_pools() { + for target in [8, 9, 10, 11] { + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (mut conn, _, mut addresses) = legacy_database(&path); + addresses[0].state = AddressState::Reserved { at: 1_700_000_001 }; + let original = insert_pool(&conn, addresses[..1].to_vec()); + let report = migrations::runner() + .set_target(refinery::Target::Version(target)) + .run(&mut conn) + .unwrap(); + assert_eq!( + report + .applied_migrations() + .iter() + .map(|m| m.version()) + .collect::>(), + (8..=target).collect::>() + ); + assert_eq!(history(&conn).last().unwrap().0, i64::from(target)); + if target < 11 { + assert_eq!( + conn.query_row( + "SELECT snapshot_blob FROM account_address_pools", + [], + |row| row.get::<_, Vec>(0) + ) + .unwrap(), + original + ); + } + drop(conn); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + let (count, reserved, key): (u32, i64, Vec) = conn + .query_row( + "SELECT count(*), reserved_at, public_key FROM core_address_pool", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!((count, reserved), (1, 1_700_000_001)); + assert_eq!(key.len(), 33); + assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_master WHERE name IN ('account_address_pools', 'core_derived_addresses')", [], |row| row.get::<_, u32>(0)).unwrap(), 0); + } +} + +#[test] +fn should_preserve_derived_only_hardened_address_with_unambiguous_owner() { + use key_wallet::account::derivation::AccountDerivation; + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (mut conn, _, _) = legacy_database(&path); + let wallet = Wallet::from_seed_bytes( + [0x61; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let account = wallet + .accounts + .account_of_type(AccountType::IdentityRegistration) + .unwrap(); + let xpriv = wallet + .derive_extended_private_key(&account.derivation_path().unwrap()) + .unwrap(); + let address = account + .derive_address_at(AddressPoolType::AbsentHardened, 3, Some(xpriv)) + .unwrap(); + assert_ne!( + account + .derive_address_at(AddressPoolType::AbsentHardened, 3, None) + .unwrap(), + address, + "public derivation does not reproduce the hardened child" + ); + insert_registration( + &conn, + &WALLET_ID, + "identity_registration", + 0, + &AccountRegistrationEntry { + account_type: AccountType::IdentityRegistration, + account_xpub: account.account_xpub, + }, + ); + conn.execute("INSERT INTO core_derived_addresses VALUES (?1, 'identity_registration', 0, ?2, 'absent_hardened/3', 1)", params![WALLET_ID.as_slice(), address.to_string()]).unwrap(); + migrations::run(&mut conn).unwrap(); + let (label, index, script, used): (String, u32, Vec, bool) = conn + .query_row( + "SELECT account_type, address_index, script, used FROM core_address_pool", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!( + (label.as_str(), index, used), + ("identity_registration", 3, true) + ); + assert_eq!(script, address.script_pubkey().to_bytes()); +} + +#[test] +fn should_preserve_provider_public_key_snapshots_without_ecdsa_registrations() { + use platform_wallet::wallet::provider_key_at_index::{ + derive_platform_node_public_keys, populate_platform_node_pool, + }; + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (mut conn, _, _) = legacy_database(&path); + let wallet = Wallet::from_seed_bytes( + [0x61; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let keys = derive_platform_node_public_keys(&wallet, Network::Testnet, 3).unwrap(); + let mut managed = ManagedWalletInfo::from_wallet(&wallet, 0); + populate_platform_node_pool(&mut managed, &keys, Network::Testnet).unwrap(); + let account = managed + .all_managed_accounts() + .into_iter() + .find(|account| { + account.managed_account_type().to_account_type() == AccountType::ProviderPlatformKeys + }) + .unwrap(); + let pool = account + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::AbsentHardened) + .unwrap(); + let mut addresses = pool.addresses.values().cloned().collect::>(); + addresses.sort_by_key(|info| info.index); + addresses[0].state = AddressState::Used; + addresses[1].state = AddressState::Reserved { at: 1_700_000_002 }; + let bytes = snapshot_bytes(&AccountAddressPoolEntry { + account_type: AccountType::ProviderPlatformKeys, + pool_type: AddressPoolType::AbsentHardened, + addresses, + }); + conn.execute("INSERT INTO account_address_pools VALUES (?1, 'provider_platform', 0, 'absent_hardened', ?2)", params![WALLET_ID.as_slice(), bytes]).unwrap(); + migrations::run(&mut conn).unwrap(); + for key in keys { + let (stored, kind): (Vec, u32) = conn.query_row("SELECT public_key, key_type FROM core_address_pool WHERE account_type = 'provider_platform' AND address_index = ?1", [key.index], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); + assert_eq!(stored, key.public_key); + assert_eq!(kind, 1); + } + assert_eq!( + conn.query_row( + "SELECT reserved_at FROM core_address_pool WHERE address_index = 1", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 1_700_000_002 + ); +} + +#[test] +fn should_reject_inconsistent_registration_without_changing_history() { + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (mut conn, _, _) = legacy_database(&path); + conn.execute("UPDATE account_registrations SET account_index = 1", []) + .unwrap(); + let original = history(&conn); + assert!(migrations::run(&mut conn).is_err()); + assert_eq!(history(&conn), original); + assert_eq!( + conn.query_row( + "SELECT account_index FROM account_registrations", + [], + |row| row.get::<_, u32>(0) + ) + .unwrap(), + 1 + ); +} + +#[test] +fn should_roll_back_converted_state_and_history_when_later_ddl_fails() { + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (mut conn, _, addresses) = legacy_database(&path); + let original_blob = insert_pool(&conn, addresses[..2].to_vec()); + let original_history = history(&conn); + conn.execute_batch("CREATE TABLE identity_scan_states (sentinel INTEGER)") + .unwrap(); + let error = migrations::run(&mut conn).unwrap_err(); + assert!( + error.report().is_none(), + "rolled-back work must not be reported as applied" + ); + assert_eq!( + history(&conn), + original_history, + "later DDL failure must roll back the entire pending upgrade" + ); + let stored: Vec = conn + .query_row( + "SELECT snapshot_blob FROM account_address_pools", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, original_blob); + conn.execute_batch("DROP TABLE identity_scan_states") + .unwrap(); + migrations::run(&mut conn).unwrap(); + let count: u32 = conn + .query_row("SELECT count(*) FROM core_address_pool", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 2); +} + +#[test] +fn should_preserve_orphan_sweep_policy_without_decoding_unreachable_blobs() { + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (mut conn, _, addresses) = legacy_database(&path); + insert_pool(&conn, addresses[..1].to_vec()); + conn.pragma_update(None, "foreign_keys", false).unwrap(); + conn.execute( + "INSERT INTO account_address_pools VALUES (?1, 'standard', 0, 'external', X'00')", + params![[0x99u8; 32].as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO account_registrations VALUES (?1, 'standard', 0, X'00')", + params![[0x99u8; 32].as_slice()], + ) + .unwrap(); + conn.pragma_update(None, "foreign_keys", true).unwrap(); + migrations::run(&mut conn).unwrap(); + assert_eq!( + conn.query_row("SELECT count(*) FROM core_address_pool", [], |row| row + .get::<_, u32>(0)) + .unwrap(), + 1 + ); +} + +#[test] +fn should_reject_conflicting_snapshot_slot_and_derived_row_atomically() { + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (mut conn, _, addresses) = legacy_database(&path); + let bytes = insert_pool(&conn, addresses[..1].to_vec()); + conn.execute( + "INSERT INTO core_derived_addresses VALUES (?1, 'standard', 0, ?2, 'external/0', 1)", + params![WALLET_ID.as_slice(), addresses[1].address.to_string()], + ) + .unwrap(); + let original = history(&conn); + assert!(migrations::run(&mut conn).is_err()); + assert_eq!(history(&conn), original); + assert_eq!( + conn.query_row( + "SELECT snapshot_blob FROM account_address_pools", + [], + |row| row.get::<_, Vec>(0) + ) + .unwrap(), + bytes + ); +} + +#[test] +fn should_hold_writer_exclusion_before_validating_and_applying_history() { + let dir = common::secure_tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let (mut conn, _, _) = legacy_database(&path); + let mut other = Connection::open(&path).unwrap(); + other.busy_timeout(std::time::Duration::ZERO).unwrap(); + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .unwrap(); + assert!(migrations::run(&mut other).is_err()); + assert_eq!(history(&tx).last().unwrap().0, 7); + tx.commit().unwrap(); + let report = migrations::run(&mut other).unwrap(); + assert_eq!(report.applied_migrations().first().unwrap().version(), 8); + assert!(migrations::run(&mut conn) + .unwrap() + .applied_migrations() + .is_empty()); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs index db12cfef2e1..bfd04156869 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs @@ -1,14 +1,9 @@ #![allow(clippy::field_reassign_with_default)] -//! `load()` reconstructs the wired-up subset of client start-state. -//! -//! The wallet-level fields (`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`. +//! `load()` reconstruction tests: `load()` returns a keyless per-wallet +//! payload (network, birth height, account manifest, core-state +//! projection, identities, `Consumed`-filtered asset locks, contacts, +//! identity keys) from which the manager re-derives the signing `Wallet`. mod common; @@ -221,15 +216,123 @@ fn wallet_without_platform_state_is_omitted_from_load() { ); } -/// 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 the `contacts` 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). +/// `load_all` reconstructs `per_account` only from *registered* accounts: +/// an address row whose `account_index` has no `platform_payment` +/// registration carries no xpub and is skipped during per-account +/// reconstruction. The reported `count` is the raw row total (the presence +/// signal for `load()`'s surfacing gate), so it still includes orphan rows. +#[test] +fn load_all_reconstructs_only_registered_accounts() { + use platform_wallet::changeset::AccountRegistrationEntry; + + let (persister, _tmp, path) = fresh_persister(); + + // Wallet A: one registered account (2 addresses) plus an orphan + // account_index with no registration (1 address). All 3 rows count, + // but only the registered account reconstructs into per_account. + let a = wid(0x70); + ensure_wallet_meta(&persister, &a); + let registered = 4u32; + let unregistered = 9u32; + let mut cs_a = PlatformWalletChangeSet::default(); + cs_a.account_registrations = vec![AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: registered, + key_class: 0, + }, + account_xpub: test_xpub(), + }]; + cs_a.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![ + entry(a, registered, 0, 0xC0), + entry(a, registered, 1, 0xC1), + entry(a, unregistered, 0, 0xC2), + ], + ..Default::default() + }); + persister.store(a, cs_a).unwrap(); + + // Wallet B: only an orphan-account row, no registration and no + // watermark — nothing reconstructs into per_account, but the raw row + // still counts and keeps the wallet above load()'s surfacing gate. + let b = wid(0x71); + ensure_wallet_meta(&persister, &b); + let mut cs_b = PlatformWalletChangeSet::default(); + cs_b.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![entry(b, unregistered, 0, 0xD0)], + ..Default::default() + }); + persister.store(b, cs_b).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let all = + platform_wallet_storage::sqlite::schema::platform_addrs::load_all(&conn).expect("load_all"); + let total_rows_a = + platform_wallet_storage::sqlite::schema::platform_addrs::count_per_wallet(&conn, &a) + .expect("count_per_wallet"); + drop(conn); + + // Sanity: wallet A really does carry the orphan row on disk. + assert_eq!(total_rows_a, 3, "wallet A has 3 platform_addresses rows"); + + let (sync_a, count_a) = all + .get(&a) + .expect("wallet A present in load_all") + .as_ref() + .expect("wallet A must read cleanly"); + assert_eq!( + *count_a, 3, + "count is the raw row total, so it includes the orphan row" + ); + assert_eq!( + sync_a.per_account.len(), + 1, + "only the registered account reconstructs into per_account" + ); + assert!( + sync_a.per_account.contains_key(®istered), + "the registered account is present in per_account" + ); + assert!( + !sync_a.per_account.contains_key(&unregistered), + "the unregistered (orphan) account is excluded from per_account" + ); + + let (sync_b, count_b) = all + .get(&b) + .expect("wallet B present in load_all") + .as_ref() + .expect("wallet B must read cleanly"); + assert_eq!(*count_b, 1, "wallet B's single orphan row still counts"); + assert!( + sync_b.per_account.is_empty(), + "an orphan-only wallet reconstructs no accounts" + ); + + // The surfacing gate is `count > 0`, so both wallets appear in load(); + // wallet B surfaces with an empty per_account rather than being dropped. + let state = p2.load().unwrap(); + assert!( + state.platform_addresses.contains_key(&a), + "wallet A reconstructs the registered account and must surface" + ); + let b_state = state + .platform_addresses + .get(&b) + .expect("wallet B surfaces because it has an address row on disk"); + assert!( + b_state.per_account.is_empty(), + "wallet B surfaces but reconstructs no accounts" + ); +} + +/// `token_balances` is persisted-but-not-rehydrated (deferred) while +/// contacts pre-key onto the owner's managed identity. Both tables are +/// durable on disk after reopen (direct SQL probes), the contact +/// round-trips onto the rehydrated identity, and `state.platform_addresses` +/// stays empty (no platform-address activity was stored). #[test] fn tc043_non_wired_up_persisted_but_not_returned() { use dpp::prelude::Identifier; @@ -296,6 +399,21 @@ fn tc043_non_wired_up_persisted_but_not_returned() { !state.platform_addresses.contains_key(&w), "no platform-address activity was stored — wallet must be absent" ); + // Contacts pre-key onto the owner's managed identity (out-of-wallet + // bucket, since the stub identity carries no `identity_index`). + let slice = state.wallets.get(&w).expect("wallet rehydrated"); + let managed = slice + .identity_manager + .out_of_wallet_identities + .get(&owner) + .expect("owner identity rehydrated"); + assert!( + managed + .dashpay() + .sent_contact_requests() + .contains_key(&recipient), + "the persisted sent contact request must rehydrate onto its identity" + ); drop(p2); let conn = common::ro_conn(&path); @@ -373,13 +491,9 @@ fn contact_request_entry(sender: u8, recipient: u8) -> ContactRequestEntry { } } -/// 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. +/// identities reader round-trips per wallet, exact equality on `id`s. +/// Exercises the hardened reader `schema::identities::load_state` +/// directly (not surfaced by `load()`), covering its fail-hard behaviour. #[test] fn tc_p4_003_load_identities_two_wallets() { use platform_wallet_storage::sqlite::schema::identities; @@ -492,10 +606,18 @@ fn tc_p4_004_load_contacts_two_wallets() { 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"); + let a_state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test( + &conn, + &a, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("contacts load_state A"); + let b_state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test( + &conn, + &b, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("contacts load_state B"); drop(conn); // Exact reconstruction: each wallet sees only its own request, with // the full `ContactRequest` surviving the round-trip. @@ -530,8 +652,12 @@ fn contacts_round_trip( let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &w) - .expect("contacts load_state") + platform_wallet_storage::sqlite::schema::contacts::load_state_for_test( + &conn, + &w, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("contacts load_state") } /// A fully-populated [`EstablishedContact`] so the round-trip exercises @@ -608,7 +734,7 @@ fn tc_p4_004b_received_only_round_trip() { } /// an established contact round-trips into `established` with -/// both request blobs and all four metadata columns intact. +/// both request blobs and all five metadata columns intact. #[test] fn tc_p4_004c_established_round_trip() { let key = SentContactRequestKey { @@ -686,8 +812,12 @@ fn tc_p4_004d_removal_deletes_pending_rows() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &w) - .expect("contacts load_state"); + let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test( + &conn, + &w, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("contacts load_state"); drop(conn); assert!(state.sent_requests.is_empty(), "removed_sent left a row"); assert!( @@ -770,8 +900,12 @@ fn tc_p4_004e_auto_establishment_collapses_pending() { assert_eq!(n, 1, "auto-establishment must collapse to a single row"); } let conn = p2.lock_conn_for_test(); - let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &w) - .expect("contacts load_state"); + let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test( + &conn, + &w, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("contacts load_state"); drop(conn); assert_eq!(state.established.get(&key), Some(&contact)); assert!( @@ -840,8 +974,12 @@ fn sent_then_matching_incoming_promotes_to_established() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &w) - .expect("contacts load_state"); + let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test( + &conn, + &w, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("contacts load_state"); drop(conn); assert!( state.established.contains_key(&est_key), @@ -908,8 +1046,12 @@ fn received_then_matching_sent_promotes_to_established() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &w) - .expect("contacts load_state"); + let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test( + &conn, + &w, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("contacts load_state"); drop(conn); assert!( state.established.contains_key(&sent_key), @@ -993,10 +1135,13 @@ fn tc_p4_005_load_asset_locks_bucketed() { 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"); + let ctx = platform_wallet_storage::LoadCtx::strict(); + let a_buckets = + platform_wallet_storage::sqlite::schema::asset_locks::load_state(&conn, &a, &ctx) + .expect("asset_locks load_state A"); + let b_buckets = + platform_wallet_storage::sqlite::schema::asset_locks::load_state(&conn, &b, &ctx) + .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); @@ -1004,8 +1149,8 @@ fn tc_p4_005_load_asset_locks_bucketed() { assert_eq!(b_buckets[&0].len(), 1); } -/// empty wallets emit `wallets_pending_rehydration = N` -/// and `wallets` slot stays empty. +/// Every persisted wallet is rehydrated into the keyless `wallets` +/// payload — `wallets_rehydrated = N`, none pending. #[tracing_test::traced_test] #[test] fn tc_p4_006_pending_rehydration_count() { @@ -1016,12 +1161,12 @@ fn tc_p4_006_pending_rehydration_count() { 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")); + assert_eq!(state.wallets.len(), 3, "all 3 wallets rehydrated"); + assert!(logs_contain("wallets_rehydrated=3")); + assert!(logs_contain("wallets_pending_rehydration=0")); } -/// load() summary carries every counter, including zeros. +/// load() summary carries the real rehydration counters. #[tracing_test::traced_test] #[test] fn tc_p4_007_summary_log_counters() { @@ -1034,8 +1179,8 @@ fn tc_p4_007_summary_log_counters() { for field in [ "wallets_seen=2", "addresses_loaded=0", - "wallets_rehydrated=0", - "wallets_pending_rehydration=2", + "wallets_rehydrated=2", + "wallets_pending_rehydration=0", ] { assert!(logs_contain(field), "missing structured field: {field}"); } @@ -1104,10 +1249,10 @@ fn tc_p4_008_corruption_is_hard_error() { assert_eq!(b_state.wallet_identities.get(&b).map(|m| m.len()), Some(1)); } -/// 008b: `contacts::load_state` is fail-hard. A garbage -/// `outgoing_request` 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. +/// `contacts::load_state` is fail-hard. A garbage `outgoing_request` +/// blob yields a typed `BincodeDecode`; a non-32-byte id column yields a +/// typed `InvalidWalletIdLength`. 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; @@ -1159,10 +1304,16 @@ fn tc_p4_008b_contacts_corruption_is_hard_error() { 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 blob_result = contacts::load_state_for_test( + &conn, + &bad_blob, + &platform_wallet_storage::LoadCtx::strict(), + ); + let id_result = + contacts::load_state_for_test(&conn, &bad_id, &platform_wallet_storage::LoadCtx::strict()); let good_state = - contacts::load_state_for_test(&conn, &good).expect("intact wallet must decode"); + contacts::load_state_for_test(&conn, &good, &platform_wallet_storage::LoadCtx::strict()) + .expect("intact wallet must decode"); drop(conn); assert!( @@ -1170,16 +1321,22 @@ fn tc_p4_008b_contacts_corruption_is_hard_error() { "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:?}" + matches!( + id_result, + Err(WalletStorageError::InvalidWalletIdLength { + column: "contacts.owner_id", + actual: 10 + }) + ), + "non-32-byte contacts id must identify contacts.owner_id and actual length 10; \ + got {id_result:?}" ); assert_eq!(good_state.sent_requests.len(), 1); } -/// 008c: `asset_locks::load_state` is fail-hard. A garbage -/// `lifecycle_blob` yields a typed `BincodeDecode`; a malformed -/// `outpoint` column yields a typed decode error. An intact wallet -/// still decodes cleanly. +/// `asset_locks::load_state` is fail-hard. A garbage `lifecycle_blob` +/// yields a typed `BincodeDecode`; a malformed `outpoint` column yields a +/// typed decode error. An intact wallet still decodes cleanly. #[test] fn tc_p4_008c_asset_locks_corruption_is_hard_error() { use dashcore::hashes::Hash; @@ -1253,9 +1410,11 @@ fn tc_p4_008c_asset_locks_corruption_is_hard_error() { 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"); + let ctx = platform_wallet_storage::LoadCtx::strict(); + let blob_result = asset_locks::load_state(&conn, &bad_blob, &ctx); + let op_result = asset_locks::load_state(&conn, &bad_op, &ctx); + let good_state = + asset_locks::load_state(&conn, &good, &ctx).expect("intact wallet must decode"); drop(conn); assert!( @@ -1272,19 +1431,18 @@ fn tc_p4_008c_asset_locks_corruption_is_hard_error() { assert_eq!(good_state[&0].len(), 1); } -/// 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`. +/// `wallets::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`). A 10-byte +/// `wallets.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; + use platform_wallet_storage::sqlite::schema::wallets; let (persister, _tmp, path) = fresh_persister(); { let conn = persister.lock_conn_for_test(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", rusqlite::params![&[0xAAu8; 10][..]], ) @@ -1294,31 +1452,25 @@ fn tc_p4_008d_list_ids_rejects_non_32_byte_wallet_id() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let result = wallet_meta::list_ids(&conn); + let result = wallets::list_ids(&conn); drop(conn); assert!( matches!( result, - Err(WalletStorageError::InvalidWalletIdLength { actual: 10 }) + Err(WalletStorageError::InvalidWalletIdLength { + column: "wallets.wallet_id", + actual: 10 + }) ), - "non-32-byte stored wallet_id must be a typed InvalidWalletIdLength {{ actual: 10 }}; \ + "non-32-byte stored wallet_id must identify wallets.wallet_id and actual length 10; \ got {result:?}" ); } -/// `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 +/// `load()` query cost is constant per wallet (no unbounded per-row +/// fan-out), without pinning a brittle magic number. Counts `Stmt` +/// events via `sqlite3_trace_v2` over one `load()`; `serial` because the +/// counter is a process-wide `AtomicUsize` (the `trace_v2` callback must /// be a `fn`, not a `Fn`). #[test] #[serial_test::serial] @@ -1375,21 +1527,37 @@ fn tc_p4_012_load_query_count_bounded() { seed_wallets(&p10, 10); let count_ten = count_load_queries(&p10); - // `load()` issues a fixed number of grouped scans regardless of - // wallet count: `wallet_meta::list_ids` plus one scan each over - // `platform_address_sync`, `platform_addresses`, and the - // `platform_payment` `account_registrations`. The count must NOT - // grow with the number of wallets — that's the constant-query - // contract. + // The per-wallet delta must be a constant (10×N readers minus the + // one shared `wallets::list_ids` divides evenly by 9), i.e. + // load() is O(1) statements per wallet — no unbounded per-row + // fan-out. The exact constant is not pinned (brittle as readers + // evolve) but it must be small and bounded. + let delta = count_ten - count_one; assert_eq!( - count_one, count_ten, - "load() query count must not grow with wallet count \ - (N=1 → {count_one}, N=10 → {count_ten})" + delta % 9, + 0, + "per-wallet statement count must be constant \ + (N=1 → {count_one}, N=10 → {count_ten}, delta → {delta})" + ); + let per_wallet = delta / 9; + assert!( + (1..=20).contains(&per_wallet), + "per-wallet statement count must be small + bounded, got {per_wallet}" ); + // Shared (wallet-count-independent) overhead: the `list_ids` + + // `platform_addrs::load_all` scans. `count_one = shared + per_wallet` + // ⇒ shared must itself be a small constant, not growing with N. + let shared = count_one - per_wallet; + assert!( + (1..=8).contains(&shared), + "shared load() overhead must be a small constant, got {shared} \ + (N=1 → {count_one}, per-wallet → {per_wallet})" + ); + // And it really is N-independent: N=10 total == shared + 10×per_wallet. assert_eq!( - count_one, 4, - "load() must issue exactly 4 grouped statements \ - (list_ids + sync + addresses + registrations), got {count_one}" + count_ten, + shared + 10 * per_wallet, + "load() statement count must be exactly shared + N×per_wallet" ); } @@ -1468,8 +1636,12 @@ fn pending_tombstones_do_not_destroy_established_rows() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &w) - .expect("contacts load_state"); + let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test( + &conn, + &w, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("contacts load_state"); let survived = state .established .get(&est_key) @@ -1519,8 +1691,12 @@ fn pending_tombstones_do_not_destroy_established_rows() { drop(persister); let p3 = reopen(&path2); let conn = p3.lock_conn_for_test(); - let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &w2) - .expect("contacts load_state"); + let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test( + &conn, + &w2, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("contacts load_state"); assert!( state.incoming_requests.is_empty(), "a received-state row is still deleted by its tombstone" diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs new file mode 100644 index 00000000000..a56487a1cfd --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs @@ -0,0 +1,159 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `SqlitePersister::load()` returns the keyless per-wallet rehydration +//! payload in `ClientStartState.wallets` (network, birth height, account +//! manifest, core state, identities, filtered asset locks), carrying no +//! `Wallet`/seed. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use platform_wallet::changeset::{ + AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + WalletMetadataEntry, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +fn reopen(path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") +} + +/// A registered wallet with UTXOs round-trips into the keyless `wallets` +/// payload — manifest, network, birth height, core state. +#[test] +fn c1_load_populates_keyless_wallet_payload() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC1); + + let seed = [0x21; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 7); + let address = WalletInfoInterface::monitored_addresses(&info) + .into_iter() + .next() + .unwrap(); + + // Registration round: metadata + per-account manifest. + let manifest: Vec = wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + let reg = PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 7, + }), + account_registrations: manifest.clone(), + ..Default::default() + }; + persister.store(w, reg).unwrap(); + + // A UTXO so the balance is non-zero. + let utxo = key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: { + use dashcore::hashes::Hash; + dashcore::Txid::from_byte_array([0x99; 32]) + }, + vout: 0, + }, + txout: dashcore::TxOut { + value: 777_000, + script_pubkey: address.script_pubkey(), + }, + address, + height: 5, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo.clone()], + last_processed_height: Some(50), + synced_height: Some(50), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load"); + + assert_eq!(state.wallets.len(), 1, "the wallet must be in the payload"); + let slice = state.wallets.get(&w).expect("wallet slice"); + assert_eq!(slice.wallet.network, key_wallet::Network::Testnet); + assert_eq!(slice.wallet_info.metadata.birth_height, 7); + // Every persisted account round-trips: the registration PK carries the + // full discriminator set (account_type, index, key_class, dashpay ids), + // so distinct variants never collapse onto one row. The rebuilt wallet's + // account collection is a faithful read of what is on disk — non-empty, + // containing the primary BIP44 account. + assert!(!slice.wallet.accounts.all_accounts().is_empty()); + assert!( + slice + .wallet + .accounts + .all_accounts() + .into_iter() + .any(|a| matches!( + a.account_type, + key_wallet::account::AccountType::Standard { .. } + )), + "BIP44 account must be in the manifest" + ); + // Core state now lives inside the assembled `core_wallet_info`: the single + // confirmed 777_000-duff UTXO restores as the wallet balance and the sync + // watermark carries over. + assert_eq!(slice.wallet_info.balance.total(), 777_000); + assert_eq!(slice.wallet_info.metadata.last_processed_height, 50); +} + +/// Empty DB → empty `wallets`, no error (the `load()` doctest contract). +#[test] +fn c2_empty_db_empty_wallets() { + let (persister, _tmp, path) = fresh_persister(); + drop(persister); + let p2 = reopen(&path); + let state = p2.load().unwrap(); + assert!(state.wallets.is_empty()); + assert!(state.is_empty()); +} + +/// A wallet with only metadata (no UTXOs) still appears, with an empty +/// core projection — not silently dropped. +#[test] +fn c3_metadata_only_wallet_present() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC3); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let state = p2.load().unwrap(); + let slice = state.wallets.get(&w).expect("metadata-only wallet present"); + assert!(slice.wallet.accounts.all_accounts().is_empty()); + assert_eq!(slice.wallet_info.balance.total(), 0); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs new file mode 100644 index 00000000000..426060a28a9 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs @@ -0,0 +1,638 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Migration execution against the populated-V001 fixture. +//! +//! Covers data preservation, the pre-migration auto-backup, that backup being +//! restorable with a deterministic re-migration, forward-version rejection at +//! the new max, idempotent re-entry, and an empty wallet through migration. + +mod common; + +use std::path::{Path, PathBuf}; + +use common::{ro_conn, wid}; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::migrations as mig; +use platform_wallet_storage::sqlite::schema::{core_pool, core_state}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; +use rusqlite::Connection; + +const FULL_WALLET: u8 = 0xA1; +const EMPTY_WALLET: u8 = 0xB2; + +/// The reuse-guard used-set `load()` assembles from a migrated store: verbatim +/// `core_address_pool` used=1 rows unioned with the `core_utxos`-derived (both +/// spent and unspent) set, deduped by script — read from the two shipped reader +/// fns the persister itself calls. The fixture's pool and UTXO identify the +/// same real address under the registered account's xpub. +fn used_set(persister: &SqlitePersister, w: &WalletId) -> Vec { + let conn = persister.lock_conn_for_test(); + let pool = core_pool::load_used_addresses(&conn, w, dashcore::Network::Testnet) + .expect("pool used-set"); + let utxo = core_state::load_used_addresses(&conn, w, dashcore::Network::Testnet) + .expect("utxo used-set"); + drop(conn); + let mut seen = std::collections::HashSet::new(); + let mut union = Vec::new(); + for addr in pool + .into_iter() + .map(|(addr, _owner)| addr) + .chain(utxo.into_iter().map(|(addr, _owner)| addr)) + { + if seen.insert(addr.script_pubkey().to_bytes()) { + union.push(addr); + } + } + union +} + +fn fixture_src() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("v4_2_dev_migrated.db") +} + +/// Copy the committed `v4.2-dev` fixture into `dir` so migration runs on a +/// throwaway copy, never the committed file. +fn copy_fixture(dir: &Path) -> PathBuf { + copy_fixture_as(dir, "wallet.db") +} + +/// [`copy_fixture`] under a caller-chosen filename, for tests that need +/// several distinctly-named databases side by side. +fn copy_fixture_as(dir: &Path, file_name: &str) -> PathBuf { + let dst = dir.join(file_name); + std::fs::copy(fixture_src(), &dst).expect("copy fixture"); + dst +} + +/// Names of the `pre-migration-*` backups sitting in `dir`. +fn pre_migration_backup_names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("read backup dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("pre-migration-") && n.ends_with(".db")) + .collect(); + names.sort(); + names +} + +fn schema_version(conn: &Connection) -> i64 { + conn.query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .unwrap() +} + +fn table_exists(conn: &Connection, table: &str) -> bool { + conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + rusqlite::params![table], + |_| Ok(()), + ) + .is_ok() +} + +fn count(conn: &Connection, sql: &str, wallet: &[u8; 32]) -> i64 { + conn.query_row(sql, rusqlite::params![wallet.as_slice()], |r| r.get(0)) + .unwrap() +} + +fn transaction_height_and_blob(conn: &Connection, wallet: &WalletId) -> (Option, Vec) { + conn.query_row( + "SELECT height, record_blob FROM core_transactions WHERE wallet_id = ?1", + rusqlite::params![wallet.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() +} + +/// Assert the post-migration store carries the full fixture data intact. +fn assert_full_data_preserved(conn: &Connection) { + let full = wid(FULL_WALLET); + assert_eq!( + schema_version(conn), + mig::max_supported_version(), + "must be migrated to the newest embedded version" + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 2, + "both wallets preserved" + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1", + &full + ), + 1 + ); + let utxos = count( + conn, + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + &full, + ); + assert_eq!(utxos, 1, "UTXO preserved"); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", + &full + ), + 1 + ); + let (height, record_blob) = transaction_height_and_blob(conn, &full); + assert_eq!(height, Some(200), "transaction height preserved by V010"); + let record: key_wallet::managed_account::transaction_record::TransactionRecord = + platform_wallet_storage::sqlite::schema::blob::decode(&record_blob) + .expect("transaction record blob preserved by V010"); + assert_eq!( + record.height(), + Some(200), + "transaction record blob retains its block context" + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + &full + ), + 1 + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM contacts WHERE wallet_id = ?1", + &full + ), + 1 + ); + // Legacy snapshots become populated per-address rows, including key and usage. + assert!(table_exists(conn, "core_address_pool")); + assert_eq!(count(conn, "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1 AND used = 1 AND length(public_key) = 33 AND key_type = 0", &full), 1); + assert!(!table_exists(conn, "account_address_pools")); + assert!(!table_exists(conn, "core_derived_addresses")); + assert!(table_exists(conn, "meta_data_versions")); + let gen_len: i64 = conn + .query_row( + "SELECT length(generation) FROM meta_store_generation WHERE id = 0", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(gen_len, 16, "generation seeded at migration"); +} + +/// A database created by a `v4.2-dev` build opens under this +/// branch, migrates the whole way forward, and keeps every pre-existing row. +/// +/// This is the acceptance test for the published-version restoration: the +/// fixture's `refinery_schema_history` was written by `v4.2-dev`'s own binary, +/// so V001-V006 must still checksum identically here, and its unstamped +/// `application_id` must not be mistaken for a foreign database. +#[test] +fn v4_2_dev_database_opens_and_migrates_forward() { + let tmp = common::secure_tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let original_transaction = { + let pre = ro_conn(&path); + assert_eq!( + schema_version(&pre), + 6, + "fixture starts at the v4.2-dev schema" + ); + transaction_height_and_blob(&pre, &wid(FULL_WALLET)) + }; + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + { + let conn = p.lock_conn_for_test(); + assert_full_data_preserved(&conn); + assert_eq!( + transaction_height_and_blob(&conn, &wid(FULL_WALLET)), + original_transaction, + "the rebuild must preserve the fixture transaction height and blob byte-for-byte" + ); + } + // The full wallet reconstructs; converted pool and UTXO usage agree. + let state = p.load().unwrap(); + let full = wid(FULL_WALLET); + assert!( + state.wallets.contains_key(&full), + "full wallet reconstructs" + ); + assert_eq!( + used_set(&p, &full).len(), + 1, + "migrated store falls back to the UTXO-derived used-set" + ); +} + +/// V014 must preserve a legacy confirmed UTXO whose transaction record was +/// never persisted and whose confirmation height therefore lives only on the +/// pre-V014 `core_utxos` row. +#[test] +fn v014_backfills_recordless_confirmed_utxo_height() { + use dashcore::hashes::Hash; + + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("recordless-v013.db"); + let wallet_id = wid(0xC3); + let txid = dashcore::Txid::from_byte_array([0x91; 32]); + let outpoint = dashcore::OutPoint::new(txid, 7); + let encoded_outpoint = + platform_wallet_storage::sqlite::schema::blob::encode_outpoint(&outpoint).unwrap(); + let legacy_unconfirmed_txid = dashcore::Txid::from_byte_array([0x92; 32]); + let legacy_unconfirmed_outpoint = dashcore::OutPoint::new(legacy_unconfirmed_txid, 8); + let encoded_legacy_unconfirmed_outpoint = + platform_wallet_storage::sqlite::schema::blob::encode_outpoint( + &legacy_unconfirmed_outpoint, + ) + .unwrap(); + { + let mut conn = Connection::open(&path).unwrap(); + mig::runner() + .set_target(refinery::Target::Version(13)) + .run(&mut conn) + .unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + rusqlite::params![wallet_id.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, spent) \ + VALUES (?1, ?2, 42, X'51', 321, 0)", + rusqlite::params![wallet_id.as_slice(), encoded_outpoint], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, spent) \ + VALUES (?1, ?2, 43, X'51', 0, 0)", + rusqlite::params![wallet_id.as_slice(), encoded_legacy_unconfirmed_outpoint], + ) + .unwrap(); + assert_eq!(schema_version(&conn), 13); + } + + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + let (height, record_blob): (Option, Option>) = conn + .query_row( + "SELECT height, record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(&txid)], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(height, Some(321)); + assert!( + record_blob.is_none(), + "backfill must create a height-only row" + ); + let legacy_unconfirmed_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![ + wallet_id.as_slice(), + AsRef::<[u8]>::as_ref(&legacy_unconfirmed_txid) + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + legacy_unconfirmed_rows, 0, + "legacy height zero was the unconfirmed sentinel and must not be backfilled" + ); +} + +/// The empty wallet inside the populated store migrates without a +/// NOT NULL violation and reads empty-but-valid. +#[test] +fn empty_wallet_through_migration() { + let tmp = common::secure_tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let state = p.load().unwrap(); + let empty = wid(EMPTY_WALLET); + assert!( + state.wallets.contains_key(&empty), + "empty wallet still surfaces post-migration" + ); + assert!( + used_set(&p, &empty).is_empty(), + "empty wallet is empty-but-valid, not corrupt" + ); +} + +/// A byte-faithful pre-migration auto-backup is written before the +/// schema changes are visible in the live file. +#[test] +fn pre_migration_backup_created() { + let tmp = common::secure_tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let backup_dir = tmp.path().join("backups"); + let p = SqlitePersister::open( + SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(backup_dir.clone())), + ) + .unwrap(); + drop(p); + + let backup = std::fs::read_dir(&backup_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + p.file_name().and_then(|n| n.to_str()).is_some_and(|n| { + n.starts_with(&format!( + "pre-migration-wallet-6-to-{}-", + mig::max_supported_version() + )) && n.ends_with(".db") + }) + }) + .expect("pre-migration backup must exist"); + + // The backup captured the PRE-migration state: the v4.2-dev schema, and + // no post-reshape table. + let bconn = ro_conn(&backup); + assert_eq!( + schema_version(&bconn), + 6, + "backup is the pre-migration v4.2-dev state" + ); + assert!( + !table_exists(&bconn, "core_address_pool"), + "backup must predate the V009 schema" + ); + // Still `wallet_metadata` in the backup: V008 is what renames it. + assert_eq!( + bconn + .query_row("SELECT COUNT(*) FROM wallet_metadata", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 2, + "backup carries the original data" + ); +} + +/// Sibling databases in one directory share the default auto-backup dir, so +/// a schema-bump boot that migrates several of them must give each its own +/// backup filename. Before the source stem was embedded, two migrations +/// landing in the same one-second timestamp produced the same name and the +/// second `open` failed with `BackupDestinationExists`. +#[test] +fn sibling_dbs_get_distinct_pre_migration_backup_names() { + let tmp = common::secure_tempdir().unwrap(); + let backup_dir = tmp.path().join("backups"); + for db_name in ["det-mainnet.sqlite", "det-testnet.sqlite"] { + let db = copy_fixture_as(tmp.path(), db_name); + SqlitePersister::open( + SqlitePersisterConfig::new(&db).with_auto_backup_dir(Some(backup_dir.clone())), + ) + .unwrap_or_else(|e| panic!("{db_name} must not collide with its sibling's backup: {e}")); + } + + let names = pre_migration_backup_names(&backup_dir); + assert_eq!( + names.len(), + 2, + "one backup per migrated database: {names:?}" + ); + for stem in ["det-mainnet", "det-testnet"] { + assert!( + names.iter().any(|n| n.contains(stem)), + "no backup names {stem} among {names:?}" + ); + } +} + +/// The pre-migration backup restores cleanly and re-migrating it +/// reaches the identical end state as a direct migration (determinism). +#[test] +fn backup_restorable_and_remigration_deterministic() { + let tmp = common::secure_tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let backup_dir = tmp.path().join("backups"); + { + let _p = SqlitePersister::open( + SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(backup_dir.clone())), + ) + .unwrap(); + } + let backup = std::fs::read_dir(&backup_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + p.file_name().and_then(|n| n.to_str()).is_some_and(|n| { + n.starts_with(&format!( + "pre-migration-wallet-6-to-{}-", + mig::max_supported_version() + )) + }) + }) + .expect("backup exists"); + + // Restore the v4.2-dev backup into a fresh dest, then reopen to re-migrate. + let dest = tmp.path().join("restored.db"); + SqlitePersister::restore_from_skip_backup(&dest, &backup).expect("restore v4.2-dev backup"); + { + let rconn = ro_conn(&dest); + assert_eq!( + schema_version(&rconn), + 6, + "restored store is at the v4.2-dev schema" + ); + } + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&dest)).unwrap(); + let conn = p2.lock_conn_for_test(); + assert_full_data_preserved(&conn); +} + +/// The forward-version gate rejects at the newest embedded +/// version; a forged row one version past it is refused. +#[test] +fn forward_version_rejected_at_new_max() { + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("wallet.db"); + { + let _p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + } + let forged = mig::max_supported_version() + 1; + { + let conn = Connection::open(&path).unwrap(); + conn.execute( + "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ + VALUES (?1, 'future', '', '0')", + rusqlite::params![forged], + ) + .unwrap(); + } + match SqlitePersister::open(SqlitePersisterConfig::new(&path)) { + Err(WalletStorageError::SchemaVersionUnsupported { + found, + max_supported, + }) => { + assert_eq!(found, forged); + assert_eq!( + max_supported, + mig::max_supported_version(), + "max must reflect the newest embedded migration" + ); + } + Err(other) => panic!("expected SchemaVersionUnsupported, got {other:?}"), + Ok(_) => panic!("forward-version DB must be refused"), + } +} + +/// A structural + row snapshot of the affected tables, for convergence +/// comparison between a clean migration and a recovered one. Excludes the +/// per-store random generation token (unique by design). +fn migration_snapshot(conn: &Connection) -> Vec { + let full = wid(FULL_WALLET); + vec![ + schema_version(conn), + conn.query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get(0)) + .unwrap(), + count( + conn, + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM contacts WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1", + &full, + ), + i64::from(table_exists(conn, "core_address_pool")), + i64::from(table_exists(conn, "meta_data_versions")), + i64::from(table_exists(conn, "meta_store_generation")), + ] +} + +/// Crash mid-migrate: an interrupted V008 (partial DDL, no commit) +/// leaves the store at the last committed version (V002) with no partial +/// tables; re-opening resumes and converges byte-equal to a clean direct +/// migration. This exercises SQLite's transactional DDL directly. The +/// production runner groups all pending SQL and typed conversion in one +/// transaction; its late-failure rollback is covered in +/// `sqlite_legacy_state_migration`. +#[test] +fn interrupted_migration_recovers_to_clean_state() { + // Reference: a fresh copy migrated straight through. + let clean_dir = common::secure_tempdir().unwrap(); + let clean_path = copy_fixture(clean_dir.path()); + let clean_snapshot = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&clean_path)).unwrap(); + let conn = p.lock_conn_for_test(); + migration_snapshot(&conn) + }; + assert_eq!( + clean_snapshot[0], + mig::max_supported_version(), + "clean migration reaches the newest embedded version" + ); + + // Crash simulation: apply part of V008's DDL inside a transaction that is + // rolled back before commit — exactly what a crash before the migration's + // single COMMIT leaves behind (SQLite DDL is transactional). + let crash_dir = common::secure_tempdir().unwrap(); + let crash_path = copy_fixture(crash_dir.path()); + { + let conn = Connection::open(&crash_path).unwrap(); + conn.execute_batch( + "BEGIN; \ + CREATE TABLE core_address_pool ( \ + wallet_id BLOB NOT NULL, account_type TEXT NOT NULL, \ + account_index INTEGER NOT NULL, \ + key_class INTEGER NOT NULL, pool_type INTEGER NOT NULL, \ + address_index INTEGER NOT NULL, script BLOB NOT NULL, \ + used INTEGER NOT NULL); \ + ROLLBACK;", + ) + .unwrap(); + // The rolled-back DDL left no trace: still V001, no partial table. + let pre = ro_conn(&crash_path); + assert_eq!( + schema_version(&pre), + 6, + "interrupted migrate stays at the v4.2-dev schema" + ); + assert!( + !table_exists(&pre, "core_address_pool"), + "partial DDL must have rolled back" + ); + } + + // Recovery: re-open runs the pending migration cleanly. + let recovered_snapshot = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&crash_path)).unwrap(); + let conn = p.lock_conn_for_test(); + migration_snapshot(&conn) + }; + assert_eq!( + recovered_snapshot, clean_snapshot, + "a store recovered from an interrupted migration must converge to the \ + same end state as a clean direct migration" + ); +} + +/// Re-entry idempotency: reopening a fully-migrated store is a no-op — no +/// further migration, and the generation token does not rotate (it only +/// rotates on migrate/restore, not a plain reopen). +#[test] +fn reopen_of_migrated_store_is_idempotent() { + let tmp = common::secure_tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let read = |conn: &Connection| -> (Vec, [u8; 16]) { + let gen: Vec = conn + .query_row( + "SELECT generation FROM meta_store_generation WHERE id = 0", + [], + |r| r.get(0), + ) + .unwrap(); + (migration_snapshot(conn), gen.try_into().unwrap()) + }; + let first = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p.lock_conn_for_test(); + read(&conn) + }; + let second = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p.lock_conn_for_test(); + read(&conn) + }; + assert_eq!( + first.0[0], + mig::max_supported_version(), + "first open migrates to the newest embedded version" + ); + assert_eq!(first, second, "reopen is a byte-stable no-op"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs index 6523c13027b..211f9dbb186 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -67,13 +67,13 @@ fn tc027_smoke_insert_every_table() { let wallet_id = [42u8; 32]; conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (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) \ + "INSERT INTO identities (wallet_id, identity_index, identity_id, entry_blob, tombstoned) \ VALUES (?1, NULL, ?2, X'01', 0)", params![wallet_id.as_slice(), identity_id.as_slice()], ) @@ -86,12 +86,7 @@ fn tc027_smoke_insert_every_table() { // Labels must match the writer-side canonical strings — see the // CHECK constraint sourced from `ACCOUNT_TYPE_LABELS` in // `sqlite::schema::accounts`. - "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')", + "INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard_bip44', 0, X'00')", &[&wallet_id.as_slice()], ), ( @@ -101,7 +96,7 @@ fn tc027_smoke_insert_every_table() { ), ( "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)", + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) VALUES (?1, ?2, 0, X'00', 0)", &[&wallet_id.as_slice(), &outpoint], ), ( @@ -109,11 +104,6 @@ fn tc027_smoke_insert_every_table() { "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)", @@ -121,10 +111,11 @@ fn tc027_smoke_insert_every_table() { ), ( "identity_keys", - // identity_keys is keyed by (identity_id, key_id); the FK - // targets identities(identity_id). - "INSERT INTO identity_keys (identity_id, key_id, public_key_blob, public_key_hash) VALUES (?1, 0, X'00', X'00')", - &[&identity_id.as_slice()], + // identity_keys is keyed by (wallet_id, identity_id, key_id); + // the wallet_id FK targets wallets and the + // identity_id FK targets identities(identity_id). + "INSERT INTO identity_keys (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) VALUES (?1, ?2, 0, X'00', X'00', NULL)", + &[&wallet_id.as_slice(), &identity_id.as_slice()], ), ( "contacts", @@ -194,6 +185,21 @@ fn tc027_smoke_insert_every_table() { .unwrap(); assert!(n >= 1, "{table} insert did not land"); } + + // `identity_keys` is counted above via the identity join, but it also + // carries its OWN `wallet_id` column (the direct per-wallet read scope); + // verify the smoke row is countable that way too. + let direct: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert!( + direct >= 1, + "identity_keys must be countable by its direct wallet_id column" + ); } /// re-open is idempotent. @@ -208,10 +214,9 @@ fn tc028_idempotent_reopen() { /// 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). +/// Asserts intra-run stability and a non-empty list — not content +/// pinning. The fingerprint is content-blind (hashes `(version, name)` +/// only), so this guards the migration set's identity, not its DDL. #[test] fn tc029_migration_fingerprint_stable() { let a = mig::embedded_migrations_fingerprint(); @@ -220,6 +225,54 @@ fn tc029_migration_fingerprint_stable() { assert!(!mig::embedded_migrations().is_empty()); } +/// `core_utxos` stores only fields used by production persistence. +#[test] +fn tc030_core_utxos_dead_metadata_columns_removed() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let mut stmt = conn.prepare("PRAGMA table_info(core_utxos)").unwrap(); + let columns: Vec = stmt + .query_map([], |row| row.get(1)) + .unwrap() + .collect::>() + .unwrap(); + + assert!(!columns.iter().any(|column| column == "account_index")); + assert!(columns.iter().any(|column| column == "spent_in_txid")); + assert!(columns + .iter() + .any(|column| column == "is_sweep_placeholder")); +} + +/// Confirmation height is single-sourced in nullable `core_transactions` rows. +#[test] +fn tc031_confirmation_height_is_single_sourced_in_core_transactions() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let mut utxo_stmt = conn.prepare("PRAGMA table_info(core_utxos)").unwrap(); + let utxo_columns: Vec = utxo_stmt + .query_map([], |row| row.get(1)) + .unwrap() + .collect::>() + .unwrap(); + assert!(!utxo_columns.iter().any(|column| column == "height")); + + let mut transaction_stmt = conn + .prepare("PRAGMA table_info(core_transactions)") + .unwrap(); + let transaction_columns: Vec<(String, bool)> = transaction_stmt + .query_map([], |row| Ok((row.get(1)?, row.get::<_, i64>(3)? == 0))) + .unwrap() + .collect::>() + .unwrap(); + assert!(transaction_columns + .iter() + .any(|(column, _nullable)| column == "height")); + assert!(transaction_columns + .iter() + .any(|(column, nullable)| column == "record_blob" && *nullable)); +} + /// load() on empty post-migrate DB is empty. #[test] fn tc044_load_empty_is_empty() { @@ -250,7 +303,8 @@ fn tc045_v004_widens_asset_lock_status_on_existing_db() { let to_v003 = mig::runner().set_target(refinery::Target::Version(3)); to_v003.run(&mut conn).expect("migrate to V003"); - // 2. Populate it the way a live wallet would have. + // 2. Populate it the way a live wallet would have. The table is still + // `wallet_metadata` here — V008 is what renames it to `wallets`. let wallet_id = [42u8; 32]; conn.execute( "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -338,7 +392,7 @@ fn tc045_v004_widens_asset_lock_status_on_existing_db() { ); assert!(garbage.is_err(), "unknown labels must still be rejected"); conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + "DELETE FROM wallets WHERE wallet_id = ?1", params![wallet_id.as_slice()], ) .expect("delete wallet"); @@ -347,3 +401,506 @@ fn tc045_v004_widens_asset_lock_status_on_existing_db() { .expect("count"); assert_eq!(remaining, 0, "ON DELETE CASCADE must survive the rebuild"); } + +/// V014 → V015 upgrade path: a database created at the prior release +/// schema (through V014) carrying a legacy empty-script spent row becomes +/// loadable again. +/// +/// The poisoned row is what the producer wrote before it reconstructed a +/// spent output's script from its typed address: `spent = 1, script = X''`. +/// `load_used_addresses` decodes every stored script with no load-policy +/// escape hatch, so that one row rejects the whole file — this test drives +/// exactly the sequence an existing install experiences, asserting the read +/// fails before the purge and recovers after it. +#[test] +fn tc046_v014_purges_legacy_empty_script_spent_utxos() { + use platform_wallet_storage::sqlite::schema::core_state; + use platform_wallet_storage::WalletStorageError; + use rusqlite::params; + + let mut conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); + conn.pragma_update(None, "foreign_keys", true) + .expect("enable foreign keys"); + + // 1. Stand the database up at the PRIOR release schema (V014). + let to_v013 = mig::runner().set_target(refinery::Target::Version(14)); + to_v013.run(&mut conn).expect("migrate to V014"); + + // 2. Two wallets: the poisoned one, and one holding the unspent + // empty-script edge case the predicate must NOT reach. + let poisoned = [42u8; 32]; + let untouched = [43u8; 32]; + for wallet_id in [poisoned, untouched] { + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .expect("insert wallet"); + } + + // P2PKH: OP_DUP OP_HASH160 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG. + let mut real_script = vec![0x76, 0xa9, 0x14]; + real_script.extend_from_slice(&[0x11u8; 20]); + real_script.extend_from_slice(&[0x88, 0xac]); + + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 1000, X'', 1)", + params![poisoned.as_slice(), [1u8; 36].as_slice()], + ) + .expect("insert poisoned row"); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 2000, ?3, 1)", + params![ + poisoned.as_slice(), + [2u8; 36].as_slice(), + real_script.as_slice() + ], + ) + .expect("insert legitimate spent row"); + // Same degenerate script, but `spent = 0`: balance state, out of the + // predicate's reach whatever the script holds. + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 3000, X'', 0)", + params![untouched.as_slice(), [3u8; 36].as_slice()], + ) + .expect("insert unspent empty-script row"); + + // 3. The damage V013 exists to repair: at V012 the single poisoned row + // rejects the used-set read for the whole wallet. + let err = core_state::load_used_addresses_with_ctx( + &conn, + &poisoned, + dashcore::Network::Testnet, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect_err("the poisoned row must reject the used-set read before V013"); + assert!( + matches!(err, WalletStorageError::AddressDecode { .. }), + "expected AddressDecode from the empty script, got {err:?}" + ); + + // 4. Upgrade to the latest schema (applies V013's purge). + mig::run(&mut conn).expect("migrate to latest"); + + // 5. The poisoned row is gone... + let poisoned_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE spent = 1 AND length(script) = 0", + [], + |row| row.get(0), + ) + .expect("count poisoned rows"); + assert_eq!( + poisoned_rows, 0, + "V013 must purge legacy empty-script spent rows" + ); + + // 6. ...the legitimate spent row beside it survived byte-identical... + let (value, script): (i64, Vec) = conn + .query_row( + "SELECT value, script FROM core_utxos WHERE wallet_id = ?1", + params![poisoned.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("the legitimate spent row survives"); + assert_eq!((value, script.as_slice()), (2000, real_script.as_slice())); + + // 7. ...so the read the poisoned row was rejecting now succeeds, and + // the real address still guards against reuse. + let used = core_state::load_used_addresses_with_ctx( + &conn, + &poisoned, + dashcore::Network::Testnet, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("the used-set read recovers after the purge"); + let used_scripts: Vec> = used + .iter() + .map(|(addr, _owner)| addr.script_pubkey().to_bytes()) + .collect(); + assert_eq!( + used_scripts, + vec![real_script], + "the real address must stay in the reuse guard" + ); + + // 8. The unspent empty-script row is untouched: the predicate is scoped + // to `spent = 1`, not "any empty script". + let unspent_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1 AND spent = 0 \ + AND length(script) = 0", + params![untouched.as_slice()], + |row| row.get(0), + ) + .expect("count unspent rows"); + assert_eq!( + unspent_rows, 1, + "an unspent row is balance state and must survive any script content" + ); +} + +/// V014 rebuilds `core_transactions` into an FK-declaring twin and backfills +/// height-only rows from `core_utxos`. Both sources can hold rows whose wallet +/// was deleted while FK enforcement happened to be off — third-party SQLite +/// tooling defaults `foreign_keys` OFF, and this database sits on an end +/// user's own device. Copying such a row under `PRAGMA foreign_keys = ON` +/// aborts the migration, and since `open` migrates on every open the database +/// then never opens again. +/// +/// Drives exactly that: one orphan in each source table, plus live rows that +/// must survive untouched. +#[test] +fn tc047_v013_drops_orphans_instead_of_aborting_the_rebuild() { + use rusqlite::params; + + let mut conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); + conn.pragma_update(None, "foreign_keys", true) + .expect("enable foreign keys"); + + // 1. Stand the database up at the PRIOR release schema (V013). + let to_v012 = mig::runner().set_target(refinery::Target::Version(13)); + to_v012.run(&mut conn).expect("migrate to V013"); + + // 2. A live wallet with one real transaction and one real UTXO. + let wallet_id = [42u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .expect("insert wallet"); + let live_txid = [7u8; 32]; + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, block_hash, block_time, \ + finalized, record_blob) VALUES (?1, ?2, 100, NULL, NULL, 1, X'AA')", + params![wallet_id.as_slice(), live_txid.as_slice()], + ) + .expect("insert live transaction"); + let live_outpoint = [0x20u8; 37]; + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, height, spent) \ + VALUES (?1, ?2, 5000, X'BB', 100, 0)", + params![wallet_id.as_slice(), live_outpoint.as_slice()], + ) + .expect("insert live utxo"); + + // 3. Plant one orphan in each source table, the way an old connection + // with FK enforcement off could have left them. + conn.pragma_update(None, "foreign_keys", false) + .expect("disable foreign keys"); + let ghost_wallet = [9u8; 32]; + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, block_hash, block_time, \ + finalized, record_blob) VALUES (?1, X'01', 5, NULL, NULL, 0, X'CC')", + params![ghost_wallet.as_slice()], + ) + .expect("insert orphan transaction with FK enforcement off"); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, height, spent) \ + VALUES (?1, X'02', 1, X'DD', 9, 0)", + params![ghost_wallet.as_slice()], + ) + .expect("insert orphan utxo with FK enforcement off"); + conn.pragma_update(None, "foreign_keys", true) + .expect("re-enable foreign keys"); + + // 4. The rebuild must complete rather than abort on the orphans. + mig::run(&mut conn).expect("migrate to latest despite the orphan rows"); + + // 5. Both orphans are gone — the same outcome the declared cascade would + // have produced had enforcement been on when the wallet was deleted. + let ghost_rows: i64 = conn + .query_row( + "SELECT (SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1) \ + + (SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1)", + params![ghost_wallet.as_slice()], + |row| row.get(0), + ) + .expect("count orphan rows"); + assert_eq!(ghost_rows, 0, "V014 must drop orphans, not abort"); + + // 6. The live transaction survived the rebuild with its record intact. + let (height, finalized, blob): (i64, i64, Vec) = conn + .query_row( + "SELECT height, finalized, record_blob FROM core_transactions WHERE txid = ?1", + params![live_txid.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("live transaction survives"); + assert_eq!((height, finalized, blob), (100, 1, vec![0xAA])); + + // 7. The live UTXO survived, and its confirmation height was backfilled + // onto a height-only `core_transactions` row. + let live_utxos: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + ) + .expect("count live utxos"); + assert_eq!(live_utxos, 1, "the live UTXO must survive the orphan sweep"); +} + +/// V008 rebuilds `identity_keys` with its own `wallet_id` scope, backfilled by +/// joining `identities`. A key whose identity is gone cannot be carried across +/// — the re-declared FK would abort the migration — so it is swept, and a key +/// belonging to a live identity must land under that identity's wallet. +#[test] +fn tc048_v007_backfills_identity_key_wallet_scope() { + use rusqlite::params; + + let mut conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); + conn.pragma_update(None, "foreign_keys", true) + .expect("enable foreign keys"); + + // The published v4.2-dev schema, before the reshape. + let to_v006 = mig::runner().set_target(refinery::Target::Version(6)); + to_v006.run(&mut conn).expect("migrate to V006"); + + let wallet_id = [0x51u8; 32]; + let owned_identity = [0x61u8; 32]; + let ghost_identity = [0x62u8; 32]; + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .expect("insert wallet"); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 0, X'00', 0)", + params![owned_identity.as_slice(), wallet_id.as_slice()], + ) + .expect("insert identity"); + conn.execute( + "INSERT INTO identity_keys (identity_id, key_id, public_key_blob, public_key_hash) \ + VALUES (?1, 0, X'00', X'00')", + params![owned_identity.as_slice()], + ) + .expect("insert key for the live identity"); + + // A key whose identity never existed, plantable only with enforcement off. + conn.pragma_update(None, "foreign_keys", false) + .expect("disable foreign keys"); + conn.execute( + "INSERT INTO identity_keys (identity_id, key_id, public_key_blob, public_key_hash) \ + VALUES (?1, 0, X'00', X'00')", + params![ghost_identity.as_slice()], + ) + .expect("insert orphan key with FK enforcement off"); + conn.pragma_update(None, "foreign_keys", true) + .expect("re-enable foreign keys"); + + mig::run(&mut conn).expect("migrate to latest despite the orphan key"); + + let scope: Vec = conn + .query_row( + "SELECT wallet_id FROM identity_keys WHERE identity_id = ?1", + params![owned_identity.as_slice()], + |row| row.get(0), + ) + .expect("live key survives the rebuild"); + assert_eq!( + scope, + wallet_id.to_vec(), + "the rebuilt key must be scoped to the wallet that owns its identity" + ); + let ghosts: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys WHERE identity_id = ?1", + params![ghost_identity.as_slice()], + |row| row.get(0), + ) + .expect("count orphan keys"); + assert_eq!( + ghosts, 0, + "a key naming no identity must be swept, not copied" + ); +} + +/// A pre-split `standard` row whose blob says BIP32 must still load cleanly +/// under the default strict policy. +/// +/// `v4.2-dev` wrote `standard` for both standard variants, so which one a row +/// is lives only in `account_xpub_bytes`. V008 therefore admits the legacy +/// label instead of rewriting it: a rewrite would have to guess, and guessing +/// BIP44 for this row would make the reader's cross-check fail and take the +/// whole wallet down under `LoadPolicy::Strict` -- a row that loads today +/// turned into a hard failure by the migration meant to carry it forward. +#[test] +fn tc049_legacy_standard_row_with_bip32_blob_still_loads() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::bip32::ExtendedPubKey; + use platform_wallet::changeset::{AccountRegistrationEntry, PlatformWalletPersistence}; + use platform_wallet_storage::sqlite::schema::blob; + use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + use rusqlite::params; + + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("legacy-standard.db"); + let wallet_id = [0x53u8; 32]; + + let xpub = ExtendedPubKey::decode(&hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC2DF1050E2E8FF49C85C2", + ).unwrap()).unwrap(); + // The blob says BIP32; the column will say the pre-split `standard`. + let entry = AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP32Account, + }, + account_xpub: xpub, + }; + let entry_blob = blob::encode(&entry).expect("encode registration"); + + { + let mut conn = rusqlite::Connection::open(&path).expect("open db"); + conn.pragma_update(None, "foreign_keys", true).unwrap(); + mig::runner() + .set_target(refinery::Target::Version(6)) + .run(&mut conn) + .expect("migrate to the v4.2-dev schema"); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .expect("insert wallet"); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'standard', 0, ?2)", + params![wallet_id.as_slice(), entry_blob], + ) + .expect("insert pre-split standard registration"); + } + + // Default config is LoadPolicy::Strict: a cross-check mismatch is fatal. + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("v4.2-dev store opens"); + let state = persister + .load() + .expect("strict load must not reject the legacy row"); + + let label: String = { + let conn = persister.lock_conn_for_test(); + conn.query_row( + "SELECT account_type FROM account_registrations WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + ) + .expect("registration survives the rebuild") + }; + assert_eq!( + label, "standard", + "the pre-split label is admitted, never rewritten to a guess" + ); + assert!( + state.wallets.contains_key(&wallet_id), + "the wallet carrying the legacy row must reconstruct" + ); +} + +/// A surviving legacy `standard` row is NOT rewritten by a later save of the +/// same account: the upsert's conflict target includes `account_type`, so the +/// writer's precise label is a different primary key and inserts a sibling row. +/// +/// Pinned because "the next write heals it" is the obvious wrong assumption to +/// make here, and because the reconciliation that makes the surviving row +/// harmless lives in the READER: `accounts::load_state` collapses the pair, so +/// the manifest carries the account once even though the table carries it +/// twice. A destructive `DELETE` in a wallet's account table to tidy a label +/// would be the wrong trade. Both halves are asserted below, because the +/// reader's guarantee is only worth anything while the writer's fork is real. +#[test] +fn tc050_legacy_standard_row_is_reconciled_by_the_reader_not_the_writer() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::bip32::ExtendedPubKey; + use platform_wallet::changeset::{ + AccountRegistrationEntry, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use platform_wallet_storage::sqlite::schema::blob; + use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + use rusqlite::params; + + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("legacy-sibling.db"); + let wallet_id = [0x54u8; 32]; + 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 entry_blob = blob::encode(&entry).unwrap(); + + { + let mut conn = rusqlite::Connection::open(&path).unwrap(); + conn.pragma_update(None, "foreign_keys", true).unwrap(); + mig::runner() + .set_target(refinery::Target::Version(6)) + .run(&mut conn) + .expect("migrate to the v4.2-dev schema"); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'standard', 0, ?2)", + params![wallet_id.as_slice(), entry_blob], + ) + .unwrap(); + } + + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let mut cs = PlatformWalletChangeSet::default(); + cs.account_registrations = vec![entry]; + persister.store(wallet_id, cs).unwrap(); + persister.flush(wallet_id).unwrap(); + + let labels: Vec = { + let conn = persister.lock_conn_for_test(); + let mut stmt = conn + .prepare( + "SELECT account_type FROM account_registrations \ + WHERE wallet_id = ?1 ORDER BY account_type", + ) + .unwrap(); + let rows = stmt + .query_map(params![wallet_id.as_slice()], |r| r.get(0)) + .unwrap() + .filter_map(Result::ok) + .collect(); + rows + }; + assert_eq!( + labels, + vec!["standard".to_string(), "standard_bip44".to_string()], + "the legacy row survives beside the writer's precise label" + ); + let manifest = { + let conn = persister.lock_conn_for_test(); + platform_wallet_storage::sqlite::schema::accounts::load_state( + &conn, + &wallet_id, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("the forked pair must not break a strict load") + }; + assert_eq!( + manifest.ecdsa.len(), + 1, + "two rows, one account: the reader must return it once, got {:?}", + manifest.ecdsa + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rs b/packages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rs new file mode 100644 index 00000000000..e792343b834 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rs @@ -0,0 +1,118 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Money/balance columns: a negative `i64` stored on disk (a wrapped +//! value, a restored-corruption row, or a torn write that passes +//! `PRAGMA integrity_check`) MUST abort the read with +//! [`WalletStorageError::IntegerOverflow`] rather than sign-extending +//! into a multi-quintillion `u64` balance. `birth_height`/`sync_height` +//! get the same guard in `sqlite_structural_hardening.rs`; here we cover +//! the genuine value-bearing columns, with `platform_addresses.balance` +//! riding the production `load()` path end-to-end. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::changeset::{ + AccountRegistrationEntry, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::WalletStorageError; +use rusqlite::params; + +/// A deterministic test xpub (BIP-32 mainnet test vector) shared by the +/// account-registration seeding helpers in this file. +fn test_xpub() -> key_wallet::bip32::ExtendedPubKey { + key_wallet::bip32::ExtendedPubKey::decode( + &hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D\ + 314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC\ + 2DF1050E2E8FF49C85C2", + ) + .unwrap(), + ) + .unwrap() +} + +/// `platform_addresses.balance`: a negative on-disk value must abort +/// the production `load()` with `IntegerOverflow{field: +/// "platform_addresses.balance"}`, NOT load a sign-extended u64 +/// balance. This rides `load() -> platform_addrs::load_all -> +/// decode_address_row -> i64_to_u64`. +/// +/// An `account_registrations` row for account 0 is seeded so the test +/// exercises the realistic production path where platform addresses are +/// always preceded by a registration. +#[test] +fn platform_address_balance_negative_on_disk_errors_on_load() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE1); + ensure_wallet_meta(&persister, &w); + + // Seed an account_registrations row for account 0 (PlatformPayment) so + // the test scenario is realistic: in production, platform_addresses rows + // are only present when the corresponding account is registered. + let mut cs = PlatformWalletChangeSet::default(); + cs.account_registrations = vec![AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 0, + key_class: 0, + }, + account_xpub: test_xpub(), + }]; + persister.store(w, cs).expect("store account registration"); + PlatformWalletPersistence::flush(&persister, w).expect("flush account registration"); + + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO platform_addresses \ + (wallet_id, account_index, address_index, address, balance, nonce) \ + VALUES (?1, 0, 0, X'0000000000000000000000000000000000000000', ?2, 0)", + params![w.as_slice(), -1i64], + ) + .unwrap(); + } + + let err = PlatformWalletPersistence::load(&persister) + .expect_err("a negative on-disk balance must abort load(), not sign-extend"); + let backend = format!("{err:?}"); + assert!( + backend.contains("IntegerOverflow") && backend.contains("platform_addresses.balance"), + "expected IntegerOverflow for platform_addresses.balance, got {backend}" + ); +} + +/// `core_utxos.value`: a negative on-disk value must abort the unspent +/// read with `IntegerOverflow{field: "core_utxos.value"}` rather than +/// reporting a sign-extended u64 amount for live funds. +#[test] +fn core_utxo_value_negative_on_disk_errors_on_read() { + use dashcore::hashes::Hash; + use platform_wallet_storage::sqlite::schema::{blob, core_state}; + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE2); + ensure_wallet_meta(&persister, &w); + let outpoint = blob::encode_outpoint(&dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0x22; 32]), + vout: 0, + }) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + // Insert an unspent UTXO so the value cast is reached. + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, ?3, X'00', 0)", + params![w.as_slice(), &outpoint, -1i64], + ) + .unwrap(); + } + let conn = persister.lock_conn_for_test(); + let err = core_state::list_unspent_utxos(&conn, &w) + .expect_err("a negative on-disk utxo value must error, not sign-extend"); + let s = format!("{err:?}"); + assert!( + matches!(err, WalletStorageError::IntegerOverflow { field, .. } if field == "core_utxos.value"), + "expected IntegerOverflow for core_utxos.value, got {s}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs index ad060bdadc5..0d24aafe2b4 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs @@ -24,11 +24,8 @@ fn id32(byte: u8) -> [u8; 32] { [byte; 32] } -// --------------------------------------------------------------------- -// 001..006 — per-scope roundtrip (get→None, put, get, overwrite, -// delete, get→None). Parent rows seeded first. -// --------------------------------------------------------------------- - +/// Full per-scope roundtrip: get→None, put, get, overwrite, delete, +/// get→None. Parent rows must be seeded first by the caller. fn roundtrip(p: &impl KvStore, scope: &ObjectId) { assert_eq!(p.get(scope, "k").unwrap(), None); p.put(scope, "k", b"v1").unwrap(); @@ -111,12 +108,6 @@ fn tc_md_006_roundtrip_platform_address() { ); } -// --------------------------------------------------------------------- -// 007..011 — parentless `put` SUCCEEDS for the five typed scopes -// (no parent row seeded) and the value reads back. — Global -// put on empty DB → Ok. -// --------------------------------------------------------------------- - /// Put `(scope, "k") = b"v"` with NO parent row present, then read it /// back. Asserts the soft-cascade model: writes don't require a parent. fn assert_parentless_put_roundtrips(p: &impl KvStore, scope: &ObjectId) { @@ -183,11 +174,8 @@ fn tc_md_012_put_global_on_empty_db_is_ok() { ); } -// --------------------------------------------------------------------- -// delete of a never-existing key is idempotent (returns Ok), -// for the Global scope and a typed scope. -// --------------------------------------------------------------------- - +/// delete of a never-existing key is idempotent for both the Global +/// scope and a typed scope. #[test] fn delete_missing_key_is_idempotent() { let (p, _tmp, _path) = fresh_persister(); @@ -197,11 +185,6 @@ fn delete_missing_key_is_idempotent() { p.delete(&ObjectId::Wallet(w), "never-existed").unwrap(); } -// --------------------------------------------------------------------- -// list_keys returns keys in ascending order regardless of -// insertion order. -// --------------------------------------------------------------------- - #[test] fn list_keys_is_ascending_regardless_of_insert_order() { let (p, _tmp, _path) = fresh_persister(); @@ -214,10 +197,8 @@ fn list_keys_is_ascending_regardless_of_insert_order() { ); } -// --------------------------------------------------------------------- -// 013..016 — soft cascade via AFTER DELETE trigger: seed+put, -// DELETE FROM the direct parent table, assert the meta row is gone. -// --------------------------------------------------------------------- +// Soft cascade via AFTER DELETE trigger: seed+put, DELETE FROM the +// direct parent table, assert the meta row is gone. #[test] fn tc_md_013_cascade_identity() { @@ -313,9 +294,7 @@ fn tc_md_016_cascade_platform_address() { assert_eq!(p.get(&scope, "k").unwrap(), None); } -// --------------------------------------------------------------------- -// 017 / 017b — wallet cascade (direct + transitive via identities). -// --------------------------------------------------------------------- +// Wallet cascade: direct, plus transitive via identities. #[test] fn tc_md_017_cascade_wallet() { @@ -328,10 +307,10 @@ fn tc_md_017_cascade_wallet() { { let conn = p.lock_conn_for_test(); conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + "DELETE FROM wallets WHERE wallet_id = ?1", params![w.as_slice()], ) - .expect("delete wallet_metadata"); + .expect("delete wallets"); } assert_eq!(p.get(&scope, "k").unwrap(), None); } @@ -349,20 +328,18 @@ fn tc_md_017b_cascade_identity_via_wallet() { { let conn = p.lock_conn_for_test(); conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + "DELETE FROM wallets WHERE wallet_id = ?1", params![w.as_slice()], ) - .expect("delete wallet_metadata"); + .expect("delete wallets"); } - // wallet_metadata delete → identities FK cascade → meta_identity + // wallets delete → identities FK cascade → meta_identity // trigger (SQLite fires it for FK-cascade-deleted rows natively). assert_eq!(p.get(&scope, "k").unwrap(), None); } -// --------------------------------------------------------------------- -// 018 / 019 — delete_wallet purges every meta_* for the wallet; -// Global + other wallet's meta_wallet survive; report wiring. -// --------------------------------------------------------------------- +// delete_wallet purges every meta_* for the wallet; Global and another +// wallet's meta_wallet survive. #[test] fn tc_md_018_delete_wallet_purges_all_meta_for_wallet() { @@ -513,12 +490,9 @@ fn tc_md_019_delete_wallet_report_counts_meta_tables() { assert_eq!(global, 1, "meta_global must survive the per-wallet delete"); } -// --------------------------------------------------------------------- -// DET scenario — write metadata before the parent exists, read it back, -// then create the parent (metadata still present), then delete the -// parent (the AFTER DELETE trigger removes the metadata). -// --------------------------------------------------------------------- - +/// Write metadata before the parent exists, read it back, create the +/// parent (metadata persists), then delete it (the AFTER DELETE trigger +/// removes the metadata). #[test] fn det_write_before_parent_then_create_then_delete() { use rusqlite::params; @@ -552,13 +526,8 @@ fn det_write_before_parent_then_create_then_delete() { assert_eq!(p.get(&scope, "alias").unwrap(), None); } -// --------------------------------------------------------------------- -// The meta_* triggers coexist with the pre-existing -// `setnull_core_utxos_on_tx_delete` trigger during delete_wallet: a -// wallet with core_transactions + core_utxos (a UTXO spent_in that tx) -// deletes cleanly and leaves nothing behind. -// --------------------------------------------------------------------- - +/// Wallet deletion cascades through core transactions and UTXOs alongside +/// the metadata cleanup triggers. #[test] fn delete_wallet_with_core_tx_and_utxo_stays_consistent() { use rusqlite::params; @@ -579,9 +548,9 @@ fn delete_wallet_with_core_tx_and_utxo_stays_consistent() { .expect("seed core_transactions"); conn.execute( "INSERT INTO core_utxos \ - (wallet_id, outpoint, value, script, account_index, spent, spent_in_txid) \ - VALUES (?1, ?2, 1000, X'00', 0, 1, ?3)", - params![w.as_slice(), outpoint.as_slice(), txid.as_slice()], + (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 1000, X'00', 1)", + params![w.as_slice(), outpoint.as_slice()], ) .expect("seed core_utxos"); } @@ -612,19 +581,15 @@ fn delete_wallet_with_core_tx_and_utxo_stays_consistent() { assert_eq!(p.get(&ObjectId::Wallet(w), "k").unwrap(), None); } -// --------------------------------------------------------------------- -// Trigger-on-FK-cascade proof at SQLite defaults. SQLite fires an AFTER -// DELETE trigger for a row removed by an FK ON DELETE CASCADE natively — -// `recursive_triggers` (off by default) does not gate this. On a RAW -// connection at defaults, the one-hop chain wallet_metadata delete → -// identities FK cascade → meta_identity trigger cleans up. -// --------------------------------------------------------------------- - +/// SQLite fires an AFTER DELETE trigger for a row removed by FK ON DELETE +/// CASCADE natively — `recursive_triggers` (off by default) does not gate +/// this. On a raw connection at defaults, the one-hop chain wallets +/// delete → identities FK cascade → meta_identity trigger cleans up. #[test] fn meta_identity_cleanup_fires_on_wallet_cascade() { use rusqlite::{params, Connection}; - let tmp = tempfile::tempdir().expect("tempdir"); + let tmp = common::secure_tempdir().expect("tempdir"); let path = tmp.path().join("raw.db"); let mut conn = Connection::open(&path).expect("open raw conn"); platform_wallet_storage::sqlite::migrations::run(&mut conn).expect("apply migration"); @@ -642,13 +607,13 @@ fn meta_identity_cleanup_fires_on_wallet_cascade() { let w = [0x90u8; 32]; let idy = [0x91u8; 32]; conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", params![&w[..]], ) .unwrap(); conn.execute( - "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ VALUES (?1, ?2, NULL, X'00', 0)", params![&idy[..], &w[..]], ) @@ -659,12 +624,9 @@ fn meta_identity_cleanup_fires_on_wallet_cascade() { ) .unwrap(); - // wallet_metadata delete → identities FK cascade → meta_identity trigger. - conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", - params![&w[..]], - ) - .unwrap(); + // wallets delete → identities FK cascade → meta_identity trigger. + conn.execute("DELETE FROM wallets WHERE wallet_id = ?1", params![&w[..]]) + .unwrap(); let identity_rows: i64 = conn .query_row( @@ -688,18 +650,14 @@ fn meta_identity_cleanup_fires_on_wallet_cascade() { ); } -// --------------------------------------------------------------------- -// Two-hop trigger-on-FK-cascade proof at SQLite defaults. The meta_token -// chain spans two FK cascades: wallet_metadata delete → identities (FK -// cascade) → token_balances (FK cascade) → meta_token trigger. This -// fires natively without recursive_triggers. -// --------------------------------------------------------------------- - +/// The meta_token chain spans two FK cascades: wallets delete → +/// identities → token_balances → meta_token trigger, firing natively +/// without recursive_triggers. #[test] fn meta_token_cleanup_fires_on_wallet_cascade_two_hops() { use rusqlite::{params, Connection}; - let tmp = tempfile::tempdir().expect("tempdir"); + let tmp = common::secure_tempdir().expect("tempdir"); let path = tmp.path().join("raw.db"); let mut conn = Connection::open(&path).expect("open raw conn"); platform_wallet_storage::sqlite::migrations::run(&mut conn).expect("apply migration"); @@ -718,13 +676,13 @@ fn meta_token_cleanup_fires_on_wallet_cascade_two_hops() { let idy = [0xA1u8; 32]; let token = [0xA2u8; 32]; conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", params![&w[..]], ) .unwrap(); conn.execute( - "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ VALUES (?1, ?2, NULL, X'00', 0)", params![&idy[..], &w[..]], ) @@ -743,11 +701,8 @@ fn meta_token_cleanup_fires_on_wallet_cascade_two_hops() { .unwrap(); // Two-hop cascade: wallet → identities → token_balances → trigger. - conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", - params![&w[..]], - ) - .unwrap(); + conn.execute("DELETE FROM wallets WHERE wallet_id = ?1", params![&w[..]]) + .unwrap(); let token_rows: i64 = conn .query_row( @@ -774,10 +729,6 @@ fn meta_token_cleanup_fires_on_wallet_cascade_two_hops() { ); } -// --------------------------------------------------------------------- -// 020..022 — key bounds. -// --------------------------------------------------------------------- - #[test] fn tc_md_020_empty_key_rejected() { let (p, _tmp, _path) = fresh_persister(); @@ -820,11 +771,8 @@ fn tc_md_022_max_length_key_accepted() { ); } -// --------------------------------------------------------------------- -// oversized value planted directly is rejected on `get` -// before materialisation, across every meta_* table. -// --------------------------------------------------------------------- - +/// An oversized value planted directly is rejected on `get` before +/// materialisation, across every meta_* table. #[test] fn tc_md_023_oversized_value_rejected_before_materialising() { use rusqlite::params; @@ -942,10 +890,8 @@ fn tc_md_023_oversized_value_rejected_before_materialising() { } } -// --------------------------------------------------------------------- -// list_keys prefix with literal `%`/`_`/`\` (not wildcards). -// --------------------------------------------------------------------- - +/// list_keys treats `%`/`_`/`\` in the prefix as literals, not LIKE +/// wildcards. #[test] fn tc_md_024_list_keys_escapes_like_metacharacters() { let (p, _tmp, _path) = fresh_persister(); @@ -977,11 +923,8 @@ fn tc_md_024_list_keys_escapes_like_metacharacters() { ); } -// --------------------------------------------------------------------- -// scope isolation: same key string across Wallet(A)/Wallet(B) -// and Global/Wallet(A) stays independent. -// --------------------------------------------------------------------- - +/// The same key string across Wallet(A)/Wallet(B) and Global/Wallet(A) +/// stays scope-independent. #[test] fn tc_md_025_scope_isolation() { let (p, _tmp, _path) = fresh_persister(); @@ -1072,14 +1015,12 @@ fn delete_wallet_leaves_no_surviving_rows() { let txid = vec![0x01u8; 32]; let outpoint = vec![0x02u8; 36]; let stmts: &[(&str, &[&dyn rusqlite::ToSql])] = &[ - ("INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard', 0, X'00')", &[&a.as_slice()]), - ("INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard', 0, 'external', X'00')", &[&a.as_slice()]), + ("INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard_bip44', 0, X'00')", &[&a.as_slice()]), ("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, X'00')", &[&a.as_slice(), &txid]), - ("INSERT INTO core_utxos (wallet_id, outpoint, value, script, account_index, spent) VALUES (?1, ?2, 0, X'00', 0, 0)", &[&a.as_slice(), &outpoint]), + ("INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) VALUES (?1, ?2, 0, X'00', 0)", &[&a.as_slice(), &outpoint]), ("INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, X'00')", &[&a.as_slice(), &txid]), - ("INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, address, derivation_path, used) VALUES (?1, 'standard', 0, 'addr', '', 0)", &[&a.as_slice()]), ("INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, 1, 1)", &[&a.as_slice()]), - ("INSERT INTO identity_keys (identity_id, key_id, public_key_blob, public_key_hash) VALUES (?1, 0, X'00', X'00')", &[&idy.as_slice()]), + ("INSERT INTO identity_keys (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) VALUES (?1, ?2, 0, X'00', X'00', NULL)", &[&a.as_slice(), &idy.as_slice()]), ("INSERT INTO platform_address_sync (wallet_id, sync_height, sync_timestamp, last_known_recent_block) VALUES (?1, 0, 0, 0)", &[&a.as_slice()]), ("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')", &[&a.as_slice(), &outpoint]), ("INSERT INTO dashpay_profiles (identity_id, profile_blob) VALUES (?1, X'00')", &[&idy.as_slice()]), @@ -1242,13 +1183,11 @@ fn delete_wallet_leaves_no_surviving_rows() { // survive. Scoping each count by `wallet_id` catches an over-broad // cascade that an unscoped whole-table COUNT(*) would miss. let wallet_scoped = [ - "wallet_metadata", + "wallets", "account_registrations", - "account_address_pools", "core_transactions", "core_utxos", "core_instant_locks", - "core_derived_addresses", "core_sync_state", "identities", "contacts", @@ -1295,7 +1234,7 @@ fn delete_wallet_leaves_no_surviving_rows() { // rows. (b is seeded in a representative subset of the scoped tables, // not all of them, so we check exactly the tables it was given.) let b_wallet_scoped = [ - "wallet_metadata", + "wallets", "core_sync_state", "identities", "contacts", 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 index 5b16833a06c..a9a3c93e684 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs @@ -53,7 +53,7 @@ fn atom_013_open_rejects_corrupt_db() { // 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)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", params![vec![i as u8; 32].as_slice(), i as i64], ) .unwrap(); @@ -120,7 +120,7 @@ fn tc_code_016_a_integrity_report_collects_all_rows() { 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)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", params![vec![i as u8; 32].as_slice(), i as i64], ) .unwrap(); 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 index 7355f587447..97da08e2df3 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_open_version_gate.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_open_version_gate.rs @@ -4,11 +4,13 @@ //! older binary would otherwise migration::run() no-op past gets //! caught at open time. +mod common; + use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; #[test] fn open_rejects_forward_schema_version() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let db_path = tmp.path().join("wallet.db"); // First open to run the embedded migrations. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_permissions.rs b/packages/rs-platform-wallet-storage/tests/sqlite_permissions.rs index 689ff8af919..3875d5cc290 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_permissions.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_permissions.rs @@ -9,18 +9,284 @@ mod common; use std::ffi::OsString; -use std::os::unix::ffi::OsStringExt; -use std::os::unix::fs::PermissionsExt; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::os::unix::fs::symlink; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; use common::{ensure_wallet_meta, wid}; use platform_wallet::changeset::{ CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, }; -use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use platform_wallet_storage::InsecureAncestor; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +#[test] +fn open_rejects_group_or_other_writable_parent() { + let tmp = common::secure_tempdir().unwrap(); + let parent = tmp.path().join("insecure"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o777)).unwrap(); + let db_path = parent.join("wallet.db"); + + let result = SqlitePersister::open(SqlitePersisterConfig::new(&db_path)); + + assert!( + matches!( + result, + Err(WalletStorageError::InsecureParentDir { + reason: InsecureAncestor::WritableWithoutSticky { mode }, + .. + }) if mode & 0o022 != 0 + ), + "open must return the typed insecure-parent error" + ); + assert!(!db_path.exists(), "the database must not be pre-created"); +} + +#[test] +fn open_rejects_parent_owned_by_another_user_when_chown_is_permitted() { + let tmp = common::secure_tempdir().unwrap(); + let parent = tmp.path().join("foreign-owner"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // SAFETY: `geteuid` takes no arguments and cannot fail. + let current_uid = unsafe { libc::geteuid() }; + let root_uid = std::fs::metadata("/").unwrap().uid(); + let foreign_uid = (1..=u16::MAX as u32) + .find(|uid| *uid != current_uid && *uid != root_uid) + .unwrap(); + let path = std::ffi::CString::new(parent.as_os_str().as_bytes()).unwrap(); + // SAFETY: `path` is a live NUL-terminated string and both IDs are valid values. + let result = unsafe { libc::chown(path.as_ptr(), foreign_uid, !0 as libc::gid_t) }; + if result != 0 { + let error = std::io::Error::last_os_error(); + if matches!(error.raw_os_error(), Some(libc::EPERM) | Some(libc::EINVAL)) { + return; + } + panic!("chown fixture failed: {error}"); + } + + let db_path = parent.join("wallet.db"); + let result = SqlitePersister::open(SqlitePersisterConfig::new(&db_path)); + + // Classified by OWNER, not by mode: 0755 is unremarkable, and reporting it + // as the fault would send the user to a `chmod` that cannot help. + assert!(matches!( + result, + Err(WalletStorageError::InsecureParentDir { + ref ancestor, + reason: InsecureAncestor::UntrustedOwner { uid, current_uid: cur }, + }) if uid == foreign_uid && cur == current_uid && ancestor == &parent + )); + assert!(!db_path.exists(), "the database must not be pre-created"); +} + +#[test] +fn open_rejects_writable_non_sticky_ancestor_above_secure_parent() { + let tmp = common::secure_tempdir().unwrap(); + let insecure_ancestor = tmp.path().join("replaceable"); + let secure_parent = insecure_ancestor.join("wallet"); + std::fs::create_dir_all(&secure_parent).unwrap(); + std::fs::set_permissions(&insecure_ancestor, std::fs::Permissions::from_mode(0o777)).unwrap(); + std::fs::set_permissions(&secure_parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + let db_path = secure_parent.join("wallet.db"); + + let result = SqlitePersister::open(SqlitePersisterConfig::new(&db_path)); + + assert!(matches!( + result, + Err(WalletStorageError::InsecureParentDir { + reason: InsecureAncestor::WritableWithoutSticky { mode }, + .. + }) if mode & 0o022 != 0 + )); + assert!( + !db_path.exists(), + "an insecure ancestor must fail before create" + ); +} + +#[test] +fn open_rejects_insecure_ancestor_reached_through_parent_symlink() { + let tmp = common::secure_tempdir().unwrap(); + let insecure_target_ancestor = tmp.path().join("replaceable-target"); + let secure_target_parent = insecure_target_ancestor.join("wallet"); + std::fs::create_dir_all(&secure_target_parent).unwrap(); + std::fs::set_permissions( + &insecure_target_ancestor, + std::fs::Permissions::from_mode(0o777), + ) + .unwrap(); + std::fs::set_permissions( + &secure_target_parent, + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let linked_parent = tmp.path().join("wallet-link"); + symlink(&secure_target_parent, &linked_parent).unwrap(); + let db_path = linked_parent.join("wallet.db"); + + let result = SqlitePersister::open(SqlitePersisterConfig::new(&db_path)); + + assert!(matches!( + result, + Err(WalletStorageError::InsecureParentDir { + reason: InsecureAncestor::WritableWithoutSticky { mode }, + .. + }) if mode & 0o022 != 0 + )); + assert!( + !secure_target_parent.join("wallet.db").exists(), + "the resolved target ancestor must be checked before create" + ); +} + +#[test] +fn open_accepts_sticky_writable_parent() { + let tmp = common::secure_tempdir().unwrap(); + let parent = tmp.path().join("shared"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o1777)).unwrap(); + let db_path = parent.join("wallet.db"); + + // The sticky bit protects EXISTING entries only: another uid cannot + // unlink or rename what it does not own, but it can still create a new + // name that does not yet exist. The create case is covered by the + // DB-path symlink refusal, not by this acceptance. + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&db_path)) + .expect("a sticky world-writable parent stays acceptable"); + drop(persister); + assert!(db_path.exists()); +} + +/// `O_CREAT|O_EXCL` reports EEXIST for a planted symlink exactly as it does +/// for a legitimate database, so EEXIST cannot be treated as "re-open". +/// Both the SQLite open and the `0o600` chmod would otherwise resolve the +/// link and land on its target. +#[test] +fn open_rejects_a_symlinked_database_path() { + let tmp = common::secure_tempdir().unwrap(); + let victim = tmp.path().join("victim.txt"); + std::fs::write(&victim, b"victim contents").unwrap(); + std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o644)).unwrap(); + let db_path = tmp.path().join("wallet.db"); + symlink(&victim, &db_path).unwrap(); + + let result = SqlitePersister::open(SqlitePersisterConfig::new(&db_path)); + + assert!( + matches!( + result, + Err(WalletStorageError::DatabasePathIsSymlink { .. }) + ), + "open must refuse a symlinked database path with the typed error" + ); + assert_eq!( + std::fs::read(&victim).unwrap(), + b"victim contents", + "the link target's contents must be untouched" + ); + assert_eq!( + std::fs::metadata(&victim).unwrap().mode() & 0o777, + 0o644, + "the owner-only chmod must not have followed the link" + ); +} + +/// The same redirect on the restore destination. `exists()` follows a link +/// to a live target, so the placeholder block cannot be the thing that +/// catches it. +#[test] +fn restore_rejects_a_symlinked_destination() { + let tmp = common::secure_tempdir().unwrap(); + let source_path = tmp.path().join("source.db"); + let source = SqlitePersister::open(SqlitePersisterConfig::new(&source_path)).unwrap(); + let backup = source.backup_to(tmp.path()).unwrap(); + drop(source); + + let victim = tmp.path().join("victim.txt"); + std::fs::write(&victim, b"victim contents").unwrap(); + let destination = tmp.path().join("restored.db"); + symlink(&victim, &destination).unwrap(); + + let result = SqlitePersister::restore_from_skip_backup(&destination, &backup); + + assert!( + matches!( + result, + Err(WalletStorageError::DatabasePathIsSymlink { .. }) + ), + "restore must refuse a symlinked destination with the typed error" + ); + assert_eq!( + std::fs::read(&victim).unwrap(), + b"victim contents", + "the link target must not be restored over" + ); +} + +#[test] +fn restore_rejects_insecure_destination_parent() { + let tmp = common::secure_tempdir().unwrap(); + let source_path = tmp.path().join("source.db"); + let source = SqlitePersister::open(SqlitePersisterConfig::new(&source_path)).unwrap(); + let backup = source.backup_to(tmp.path()).unwrap(); + drop(source); + + let insecure_parent = tmp.path().join("restore-target"); + std::fs::create_dir(&insecure_parent).unwrap(); + std::fs::set_permissions(&insecure_parent, std::fs::Permissions::from_mode(0o777)).unwrap(); + let destination = insecure_parent.join("restored.db"); + + let result = SqlitePersister::restore_from_skip_backup(&destination, &backup); + + assert!(matches!( + result, + Err(WalletStorageError::InsecureParentDir { + reason: InsecureAncestor::WritableWithoutSticky { mode }, + .. + }) if mode & 0o022 != 0 + )); + assert!( + !destination.exists(), + "restore must reject the parent before staging or replacing the destination" + ); +} + +#[test] +fn restore_checks_destination_permissions_before_auto_backup_policy() { + let tmp = common::secure_tempdir().unwrap(); + let source_path = tmp.path().join("source-for-ordinary-restore.db"); + let source = SqlitePersister::open(SqlitePersisterConfig::new(&source_path)).unwrap(); + let backup = source.backup_to(tmp.path()).unwrap(); + drop(source); + + let insecure_parent = tmp.path().join("ordinary-restore-target"); + std::fs::create_dir(&insecure_parent).unwrap(); + std::fs::set_permissions(&insecure_parent, std::fs::Permissions::from_mode(0o777)).unwrap(); + let destination = insecure_parent.join("restored.db"); + std::fs::write(&destination, b"existing destination").unwrap(); + + let result = SqlitePersister::restore_from(&destination, &backup, None); + + assert!(matches!( + result, + Err(WalletStorageError::InsecureParentDir { + reason: InsecureAncestor::WritableWithoutSticky { mode }, + .. + }) if mode & 0o022 != 0 + )); + assert_eq!( + std::fs::read(&destination).unwrap(), + b"existing destination", + "the gate must run before opening or backing up the destination" + ); +} #[test] fn wal_and_shm_sidecars_are_chmodded_0o600() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let db_path = tmp.path().join("wallet.db"); let persister = SqlitePersister::open(SqlitePersisterConfig::new(&db_path)).expect("open"); @@ -73,7 +339,7 @@ fn wal_and_shm_sidecars_are_chmodded_0o600() { /// have mangled into the wrong sibling names. #[test] fn tc_code_011_a_non_ascii_db_path_sidecars_chmodded() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_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. @@ -117,7 +383,7 @@ fn tc_code_011_a_non_ascii_db_path_sidecars_chmodded() { /// race window. #[test] fn tc_code_011_b_no_sidecars_is_ok() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let db_path = tmp.path().join("solo.db"); std::fs::write(&db_path, b"x").unwrap(); // No -wal / -shm planted on purpose. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs index 3f05d82c766..9757deee4de 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -1,17 +1,10 @@ #![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. +//! Per-sub-changeset round-trip tests: store a non-trivial entry, reopen +//! the persister, decode the bincode-serde blob, and assert structural +//! equality (or field-level equality where the type isn't `PartialEq`). +//! CoreChangeSet records and multi-wallet coexistence are covered in +//! `sqlite_buffer_semantics.rs`. mod common; @@ -74,7 +67,7 @@ fn tc013_wallet_metadata_roundtrip() { 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", + "SELECT network, birth_height FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| Ok((row.get(0)?, row.get(1)?)), ) @@ -87,7 +80,7 @@ fn tc013_wallet_metadata_roundtrip() { /// `ConfigInvalid` error and the DB is not created. #[test] fn tc_code_029_1_journal_mode_memory_rejected() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let path = tmp.path().join("w.db"); let mut cfg = SqlitePersisterConfig::new(&path); cfg.journal_mode = JournalMode::Memory; @@ -108,7 +101,7 @@ fn tc_code_029_1_journal_mode_memory_rejected() { /// error and the DB is not created. #[test] fn tc_code_029_2_journal_mode_off_rejected() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let path = tmp.path().join("w.db"); let mut cfg = SqlitePersisterConfig::new(&path); cfg.journal_mode = JournalMode::Off; @@ -130,7 +123,7 @@ fn tc_code_029_2_journal_mode_off_rejected() { #[test] #[tracing_test::traced_test] fn tc_code_029_3_busy_timeout_zero_warns() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let path = tmp.path().join("w.db"); let mut cfg = SqlitePersisterConfig::new(&path); cfg.busy_timeout = std::time::Duration::ZERO; @@ -145,7 +138,7 @@ fn tc_code_029_3_busy_timeout_zero_warns() { /// TC-079: synchronous=Off is rejected at open with a typed error. #[test] fn tc079_synchronous_off_rejected() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let path = tmp.path().join("w.db"); let mut cfg = SqlitePersisterConfig::new(&path); cfg.synchronous = Synchronous::Off; @@ -248,8 +241,8 @@ fn tc007_identity_key_entry_roundtrip() { let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); let conn = p2.lock_conn_for_test(); - // identity_keys is keyed by (identity_id, key_id); the wallet_id - // column is not part of the schema. + // `(identity_id, key_id)` IS the primary key, so this selects the + // one row outright. let blob_bytes: Vec = conn .query_row( "SELECT public_key_blob FROM identity_keys WHERE identity_id = ?1 AND key_id = ?2", @@ -260,12 +253,9 @@ fn tc007_identity_key_entry_roundtrip() { 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. + // No runtime substring scan on the blob: bincode wire bytes carry no + // field names, so it would be false confidence. The real secret-leak + // guard is the source grep in `tests/secrets_scan.rs`. drop(tmp); } @@ -441,6 +431,7 @@ fn tc010_asset_lock_roundtrip() { let bucketed = platform_wallet_storage::sqlite::schema::asset_locks::load_state( &p2.lock_conn_for_test(), &w, + &platform_wallet_storage::LoadCtx::strict(), ) .unwrap(); let by_outpoint = &bucketed[&5]; @@ -509,6 +500,7 @@ fn tc010b_recovered_from_chain_lock_roundtrip() { let bucketed = platform_wallet_storage::sqlite::schema::asset_locks::load_state( &p2.lock_conn_for_test(), &w, + &platform_wallet_storage::LoadCtx::strict(), ) .unwrap(); let tracked = &bucketed[&0][&outpoint]; @@ -633,6 +625,7 @@ fn tc010c_stale_recovery_snapshot_cannot_regress_consumed_row() { let bucketed = platform_wallet_storage::sqlite::schema::asset_locks::load_state( &p2.lock_conn_for_test(), &w, + &platform_wallet_storage::LoadCtx::strict(), ) .unwrap(); assert_eq!( diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs new file mode 100644 index 00000000000..45f3dda8283 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs @@ -0,0 +1,402 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Verbatim pool-snapshot reader. +//! +//! Covers the used-set coming from `core_address_pool` rather than a +//! `core_utxos` re-derivation, the deep-derivation window with no horizon-walk +//! truncation, an empty wallet loading empty-but-valid, multi-wallet isolation, +//! and the pool ∪ `core_utxos` used-set union (pre-pool + mixed stores). +//! +//! These assert directly on the two shipped reader fns `load()` itself calls — +//! `core_pool::load_used_addresses` (verbatim pool `used=1`) and +//! `core_state::load_used_addresses` (`core_utxos`-derived, spent + unspent) — +//! not on `load()`'s assembled `core_wallet_info`. The reuse-guard facts pinned +//! here (deep-index no-truncation, pool ∪ UTXO dedup) live at the reader layer: +//! `load()` only marks addresses that resolve to a *registered* account's +//! derived pool, which these keyless, arbitrary-address stores deliberately +//! don't set up. The `load()` → `core_wallet_info` marking path is covered by +//! `sqlite_used_core_addresses.rs`. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::address::Payload; +use dashcore::hashes::Hash; +use dashcore::{Address, Network, PubkeyHash}; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::{AddressPoolType, AddressState}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Utxo}; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::{core_pool, core_state}; +use platform_wallet_storage::SqlitePersister; + +/// Verbatim `core_address_pool` `used=1` addresses — the pool half of the +/// reuse-guard set `load()` reads (owner dropped; these tests assert addresses). +fn pool_used(persister: &SqlitePersister, w: &WalletId) -> Vec
{ + let conn = persister.lock_conn_for_test(); + core_pool::load_used_addresses_with_ctx( + &conn, + w, + Network::Testnet, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("pool used-set") + .into_iter() + .map(|(addr, _owner)| addr) + .collect() +} + +/// `core_utxos`-derived used addresses (spent + unspent) — the UTXO half. +fn utxo_used(persister: &SqlitePersister, w: &WalletId) -> Vec
{ + let conn = persister.lock_conn_for_test(); + core_state::load_used_addresses_with_ctx( + &conn, + w, + Network::Testnet, + &platform_wallet_storage::LoadCtx::strict(), + ) + .expect("utxo used-set") + .into_iter() + .map(|(addr, _owner)| addr) + .collect() +} + +/// The assembled reuse-guard set `load()` hands the manager: pool ∪ UTXO, +/// deduped by script (mirrors `SqlitePersister::load`). +fn used_set(persister: &SqlitePersister, w: &WalletId) -> Vec
{ + let conn = persister.lock_conn_for_test(); + let ctx = platform_wallet_storage::LoadCtx::strict(); + let pool = core_pool::load_used_addresses_with_ctx(&conn, w, Network::Testnet, &ctx) + .expect("pool used-set"); + let utxo = core_state::load_used_addresses_with_ctx(&conn, w, Network::Testnet, &ctx) + .expect("utxo used-set"); + drop(conn); + let mut seen = std::collections::HashSet::new(); + let mut union = Vec::new(); + for addr in pool + .into_iter() + .map(|(addr, _owner)| addr) + .chain(utxo.into_iter().map(|(addr, _owner)| addr)) + { + if seen.insert(addr.script_pubkey().to_bytes()) { + union.push(addr); + } + } + union +} + +fn external_infos(seed_byte: u8) -> Vec { + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_account_type(), + AccountType::Standard { index: 0, .. } + ) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type == AddressPoolType::External && !pool.addresses.is_empty() { + let mut infos: Vec = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos; + } + } + } + panic!("no external pool"); +} + +fn p2pkh(byte: u8) -> Address { + Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([byte; 20])), + ) +} + +/// The used-set is the verbatim pool `used=1` state, computed +/// without touching `core_utxos`: no UTXO is stored, yet the used addresses +/// surface (a projection-derived reader would return an empty set). +#[test] +fn used_set_from_pool_not_utxos() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x20); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0x20); + infos.truncate(10); + assert_eq!(infos.len(), 10); + let used_indices = [0u32, 3, 7]; + for info in infos.iter_mut() { + info.state = if used_indices.contains(&info.index) { + AddressState::Used + } else { + AddressState::Available + }; + } + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + pool_type: AddressPoolType::External, + addresses: infos.clone(), + }], + ..Default::default() + }, + ) + .unwrap(); + + let got: std::collections::BTreeSet = pool_used(&persister, &w) + .iter() + .map(|a| a.to_string()) + .collect(); + let expected: std::collections::BTreeSet = infos + .iter() + .filter(|i| used_indices.contains(&i.index)) + .map(|i| i.address.to_string()) + .collect(); + assert_eq!(got, expected, "used-set must equal the pool's used=1 rows"); + assert!( + utxo_used(&persister, &w).is_empty(), + "no UTXO stored: the used-set is pool-derived, not core_utxos-derived" + ); +} + +/// A wallet whose pool advanced past the old horizon-walk window +/// (used up to index 45, then 30 unused) restores its full used-set: the +/// index-45 address is present, never truncated at 30. +#[test] +fn deep_derivation_window_not_truncated() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x23); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + for i in 0u32..=75 { + let used = i32::from(i <= 45); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, ?2, ?3, ?4)", + rusqlite::params![ + w.as_slice(), + i64::from(i), + p2pkh(i as u8).script_pubkey().as_bytes(), + used + ], + ) + .unwrap(); + } + } + + let used = pool_used(&persister, &w); + assert_eq!( + used.len(), + 46, + "indices 0..=45 are used and must all restore" + ); + let want = p2pkh(45).to_string(); + assert!( + used.iter().any(|a| a.to_string() == want), + "the index-45 used address must survive (no gap-limit-30 truncation)" + ); +} + +/// An empty wallet (a `wallets` row, no pool rows, no UTXOs) +/// loads as empty-but-valid: present with an empty used-set, not corrupt. +#[test] +fn empty_wallet_is_empty_but_valid() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x25); + ensure_wallet_meta(&persister, &w); + + assert!( + used_set(&persister, &w).is_empty(), + "empty wallet has an empty used-set" + ); +} + +fn utxo_on(addr: &Address, byte: u8, value: u64) -> Utxo { + Utxo::new( + dashcore::OutPoint::new(dashcore::Txid::from_byte_array([byte; 32]), 0), + dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + addr.clone(), + 10, + false, + ) +} + +/// A pre-pool store (UTXOs, no `core_address_pool` rows) yields the +/// reuse-guard set from the `core_utxos`-derived half of the union. +#[test] +fn pre_pool_store_yields_utxo_derived_used_set() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x26); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x99); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr, 0x11, 1000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let used = utxo_used(&persister, &w); + assert_eq!(used.len(), 1); + assert_eq!(used[0].to_string(), addr.to_string()); +} + +/// Reader multi-wallet isolation: two wallets seeded with +/// distinct, distinguishable used addresses (and balances) load such that +/// neither wallet's snapshot shows the other's — no cross-wallet leakage. +#[test] +fn reader_isolates_two_wallets() { + let (persister, _tmp, _path) = fresh_persister(); + let a: WalletId = wid(0x2A); + let b: WalletId = wid(0x2B); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + + let addr_a = p2pkh(0xA1); + let addr_b = p2pkh(0xB1); + persister + .store( + a, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr_a, 0x01, 111)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + b, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr_b, 0x02, 222)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let a_used: Vec = used_set(&persister, &a) + .iter() + .map(|x| x.to_string()) + .collect(); + let b_used: Vec = used_set(&persister, &b) + .iter() + .map(|x| x.to_string()) + .collect(); + assert_eq!( + a_used, + vec![addr_a.to_string()], + "A sees only its own address" + ); + assert_eq!( + b_used, + vec![addr_b.to_string()], + "B sees only its own address" + ); + assert!( + !a_used.contains(&addr_b.to_string()), + "A must not see B's address" + ); + assert!( + !b_used.contains(&addr_a.to_string()), + "B must not see A's address" + ); +} + +/// Mixed-store regression — a historical `core_utxos` address that +/// a later partial pool snapshot never enumerates must surface BOTH the +/// historical UTXO address and the pool used address. The union must never +/// let the pool set shadow the historical one (address-reuse / funds safety). +#[test] +fn mixed_store_unions_utxo_and_pool_used_sets() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x27); + ensure_wallet_meta(&persister, &w); + + // Historical UTXO on address X, written before any pool snapshot exists. + let historical = p2pkh(0xAA); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&historical, 0x12, 500)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + // A later pool snapshot marks a DIFFERENT address Y used and does not + // enumerate the historical address at all. + let mut infos = external_infos(0x27); + infos.truncate(1); + infos[0].state = AddressState::Used; + let pool_used = infos[0].address.clone(); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + pool_type: AddressPoolType::External, + addresses: infos.clone(), + }], + ..Default::default() + }, + ) + .unwrap(); + + let got: std::collections::BTreeSet = used_set(&persister, &w) + .iter() + .map(|a| a.to_string()) + .collect(); + assert!( + got.contains(&historical.to_string()), + "historical UTXO address must survive a later partial pool snapshot" + ); + assert!( + got.contains(&pool_used.to_string()), + "pool used address must be present" + ); + assert_eq!(got.len(), 2, "exactly the union of both sources, deduped"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_provider_key_accounts.rs b/packages/rs-platform-wallet-storage/tests/sqlite_provider_key_accounts.rs new file mode 100644 index 00000000000..1a517f6dc80 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_provider_key_accounts.rs @@ -0,0 +1,738 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Provider key-material account persistence (dashpay/platform#4113): the BLS +//! operator-key and EdDSA platform-node-key accounts survive `store()` → +//! `load()` on par with the ECDSA `account_registrations` manifest. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::account::AccountType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use platform_wallet::changeset::{ + AccountRegistrationEntry, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, WalletMetadataEntry, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::versions::{self, Domain}; +use platform_wallet_storage::sqlite::schema::{accounts, blob}; +use platform_wallet_storage::{ + LoadCtx, LoadSite, SqlitePersister, SqlitePersisterConfig, WalletStorageError, +}; + +/// Deterministic seed wallet carrying both provider key-material accounts +/// (`WalletAccountCreationOptions::Default` creates every special-purpose +/// account, BLS operator + EdDSA platform node included). +fn seed_wallet(seed: u8) -> Wallet { + Wallet::from_seed_bytes( + [seed; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet") +} + +/// The wallet's BLS operator-key account xpub. +fn bls_xpub(seed: u8) -> ProviderKeyExtendedPubKey { + ProviderKeyExtendedPubKey::Bls( + seed_wallet(seed) + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS operator account") + .bls_public_key + .clone(), + ) +} + +/// The wallet's EdDSA platform-node-key account xpub. +fn eddsa_xpub(seed: u8) -> ProviderKeyExtendedPubKey { + ProviderKeyExtendedPubKey::EdDSA( + seed_wallet(seed) + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA platform account") + .ed25519_public_key + .clone(), + ) +} + +fn operator_entry(seed: u8) -> ProviderKeyAccountEntry { + ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: bls_xpub(seed), + } +} + +fn platform_entry(seed: u8) -> ProviderKeyAccountEntry { + ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: eddsa_xpub(seed), + } +} + +fn metadata() -> WalletMetadataEntry { + WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + } +} + +fn reopen(path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen persister") +} + +/// Raw Ed25519 public-key bytes of an extended key, for cross-curve +/// comparison without leaning on a `PartialEq` the BLS type does not have. +fn eddsa_bytes(key: &ProviderKeyExtendedPubKey) -> Vec { + match key { + ProviderKeyExtendedPubKey::EdDSA(k) => k.encode().to_vec(), + other => panic!("expected an EdDSA key, got {other:?}"), + } +} + +/// BLS public-key bytes of an extended key (the G2 element), same purpose. +fn bls_bytes(key: &ProviderKeyExtendedPubKey) -> Vec { + match key { + ProviderKeyExtendedPubKey::Bls(k) => k.public_key.to_bytes().to_vec(), + other => panic!("expected a BLS key, got {other:?}"), + } +} + +fn wallet_storage_error(err: PersistenceError) -> Box { + let source = match err { + PersistenceError::Backend { source, .. } => source, + other => panic!("expected Backend {{ .. }}, got {other:?}"), + }; + source + .downcast::() + .unwrap_or_else(|source| panic!("expected WalletStorageError, got {source}")) +} + +/// A reloaded wallet gets its BLS operator and EdDSA +/// platform-node accounts back. The end-to-end contract #4113 exists for: +/// a seedless/external-signable wallet can list its provider key accounts +/// after a restart without the mnemonic. +#[test] +fn provider_accounts_survive_store_load() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xC1); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(metadata()), + provider_key_account_registrations: vec![ + operator_entry(0x21), + platform_entry(0x21), + ], + ..Default::default() + }, + ) + .expect("store"); + drop(persister); + + let persister = reopen(&path); + let state = persister.load().expect("load"); + let restored = &state.wallets.get(&w).expect("wallet rehydrated").wallet; + + let bls = restored + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS operator account must come back from persistence"); + assert_eq!( + bls.bls_public_key.public_key.to_bytes().to_vec(), + bls_bytes(&bls_xpub(0x21)), + "the restored BLS account must carry the persisted operator xpub" + ); + assert!(bls.is_watch_only, "a rehydrated account is watch-only"); + + let eddsa = restored + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA platform account must come back from persistence"); + assert_eq!( + eddsa.ed25519_public_key.encode().to_vec(), + eddsa_bytes(&eddsa_xpub(0x21)), + "the restored EdDSA account must carry the persisted platform-node xpub" + ); + assert!(eddsa.is_watch_only, "a rehydrated account is watch-only"); +} + +/// A changeset carrying only provider-key registrations bumps +/// the `account_registrations` domain seq and no other. The forgotten-domain +/// guard (R8): a field that reaches the DB must invalidate a cache. +#[test] +fn provider_only_changeset_bumps_account_registrations_domain() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC2); + ensure_wallet_meta(&persister, &w); + + let cs = PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x22)], + ..Default::default() + }; + assert_eq!( + versions::touched_domains(&cs), + vec![Domain::AccountRegistrations], + "provider-key registrations must map to the account-registrations domain" + ); + + persister.store(w, cs).expect("store"); + + let conn = persister.lock_conn_for_test(); + for domain in Domain::ALL { + let seq = versions::read_seq(&conn, &w, domain).expect("read_seq"); + let expected = i64::from(domain == Domain::AccountRegistrations); + assert_eq!( + seq, expected, + "{domain:?} seq must be {expected} after a provider-only store" + ); + } +} + +/// A BLS operator account decodes back as BLS, never as the +/// other curve, and its xpub bytes survive verbatim. +#[test] +fn bls_decodes_as_bls() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC3); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x23)], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + let provider = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state") + .provider; + assert_eq!(provider.len(), 1); + assert!( + matches!( + provider[0].extended_public_key, + ProviderKeyExtendedPubKey::Bls(_) + ), + "a ProviderOperatorKeys account must decode as BLS, got {:?}", + provider[0].extended_public_key + ); + assert_eq!( + bls_bytes(&provider[0].extended_public_key), + bls_bytes(&bls_xpub(0x23)), + "BLS xpub must survive byte-for-byte" + ); +} + +/// An EdDSA account decodes as EdDSA, and a row whose +/// `account_type` column claims the other curve's account is rejected: the +/// column is the decode discriminator, so cross-curve confusion must be a +/// hard error, never a silently-wrong account. +#[test] +fn eddsa_decodes_as_eddsa_and_cross_curve_row_is_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC4); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![platform_entry(0x24)], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + let provider = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state") + .provider; + assert_eq!(provider.len(), 1); + assert!( + matches!( + provider[0].extended_public_key, + ProviderKeyExtendedPubKey::EdDSA(_) + ), + "a ProviderPlatformKeys account must decode as EdDSA" + ); + assert_eq!( + eddsa_bytes(&provider[0].extended_public_key), + eddsa_bytes(&eddsa_xpub(0x24)), + "EdDSA xpub must survive byte-for-byte" + ); + + drop(conn); + + // Negative half, on a BLS row. Two corruptions the writer cannot produce + // but a schema bug or a tampered DB can, each rejected by a different + // guard: + // (a) the blob's account type contradicts the `account_type` column; + // (b) the column and the blob agree on the type, but the blob carries + // the other curve's key — only the curve check catches this one. + let w2: WalletId = wid(0xD4); + ensure_wallet_meta(&persister, &w2); + persister + .store( + w2, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x24)], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + let plant = |payload: Vec| { + conn.execute( + "UPDATE account_registrations SET account_xpub_bytes = ?2 WHERE wallet_id = ?1", + rusqlite::params![w2.as_slice(), payload], + ) + .expect("plant corrupt provider row"); + }; + + for (case, payload, recovery_site) in [ + ( + "blob type contradicts the account_type column", + ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: eddsa_xpub(0x24), + }, + LoadSite::ProviderKeyRegistrationDrift, + ), + ( + "blob carries the wrong curve for its account type", + ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: eddsa_xpub(0x24), + }, + LoadSite::ProviderKeyCurveMismatch, + ), + ] { + plant(blob::encode(&payload).expect("encode")); + let err = accounts::load_state(&conn, &w2, &platform_wallet_storage::LoadCtx::strict()) + .expect_err("cross-curve row must hard-error"); + assert!( + matches!(err, WalletStorageError::ProviderKeyAccountEntryMismatch), + "{case}: expected ProviderKeyAccountEntryMismatch, got {err:?}" + ); + + let ctx = LoadCtx::recovery(); + let manifest = accounts::load_state(&conn, &w2, &ctx) + .expect("recovery skips the corrupt provider row"); + assert!(manifest.provider.is_empty()); + let degradation = ctx.degradation(); + assert_eq!(degradation.by_site.get(&recovery_site), Some(&1)); + assert_eq!(degradation.by_site.len(), 1); + } +} + +/// No provider accounts is a clean round-trip: no rows, no +/// error, and the ECDSA manifest beside it is untouched. +#[test] +fn empty_provider_set_round_trips() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC6); + ensure_wallet_meta(&persister, &w); + + let ecdsa = AccountRegistrationEntry { + account_type: AccountType::IdentityRegistration, + account_xpub: seed_wallet(0x26) + .accounts + .all_accounts() + .first() + .expect("an account") + .account_xpub, + }; + persister + .store( + w, + PlatformWalletChangeSet { + account_registrations: vec![ecdsa], + provider_key_account_registrations: vec![], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + let manifest = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + assert!(manifest.provider.is_empty(), "no provider accounts stored"); + assert_eq!(manifest.ecdsa.len(), 1, "the ECDSA entry is unaffected"); +} + +/// A corrupt provider blob fails the whole load. Skipping the +/// row would hand back a wallet silently missing its operator account. +#[test] +fn corrupt_provider_blob_hard_errors() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC7); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x27)], + ..Default::default() + }, + ) + .expect("store"); + + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE account_registrations SET account_xpub_bytes = X'00' WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + ) + .expect("corrupt the blob"); + + let err = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect_err("a corrupt provider blob must hard-error"); + assert!( + matches!(err, WalletStorageError::BincodeDecode { .. }), + "expected a typed BincodeDecode, got {err:?}" + ); +} + +/// An oversize provider blob is rejected by the `length()` gate +/// before the `Vec` is materialized. +#[test] +fn oversize_provider_blob_is_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xC8); + ensure_wallet_meta(&persister, &w); + + let oversize = vec![0u8; blob::BLOB_SIZE_LIMIT_BYTES + 1]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'provider_operator', 0, ?2)", + rusqlite::params![w.as_slice(), oversize], + ) + .expect("insert oversize provider blob"); + + let err = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect_err("oversize blob must be rejected"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +/// A retried `store()` updates the account row in place. +#[test] +fn idempotent_repersist_does_not_duplicate() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xCA); + ensure_wallet_meta(&persister, &w); + + for _ in 0..2 { + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![ + operator_entry(0x2A), + platform_entry(0x2A), + ], + ..Default::default() + }, + ) + .expect("store"); + } + + let conn = persister.lock_conn_for_test(); + let manifest = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + assert_eq!(manifest.provider.len(), 2, "re-persist must not duplicate"); +} + +/// The writer enforces the same curve/type pairing as the reader. +/// The two writers share one table, one PK space and one blob column, +/// discriminated only by `account_type`: a `ProviderOperatorKeys` entry +/// carrying an EdDSA key would upsert onto the operator account's row with a +/// payload the fail-hard reader then rejects — bricking `load()` for the whole +/// wallet. Reject it at write time instead of storing a landmine. +#[test] +fn writer_rejects_mispaired_curve_and_account_type() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD1); + ensure_wallet_meta(&persister, &w); + + // ProviderOperatorKeys (BLS by contract) carrying an EdDSA key. + let mispaired = ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: eddsa_xpub(0x31), + }; + let err = persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![mispaired], + ..Default::default() + }, + ) + .expect_err("a mis-paired provider entry must be rejected at write time"); + assert!( + format!("{err:?}").contains("ProviderKeyAccountEntryMismatch"), + "expected a curve/type mismatch, got {err:?}" + ); + + let conn = persister.lock_conn_for_test(); + assert_eq!( + account_row_count(&conn, &w), + 0, + "a rejected entry must leave no row behind" + ); +} + +/// Two entries for one account that disagree about its extended public key are +/// a contradiction no merge semantic can resolve — one is wrong and the store +/// cannot tell which. Fail closed rather than let write order pick the winner. +#[test] +fn conflicting_duplicate_provider_entries_are_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD2); + ensure_wallet_meta(&persister, &w); + + // Same account type, two different seeds → two different xpubs. + let err = persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![ + platform_entry(0x32), + platform_entry(0x33), + ], + ..Default::default() + }, + ) + .expect_err("conflicting entries for one account must be rejected"); + assert!( + format!("{err:?}").contains("ProviderKeyAccountConflict"), + "expected ProviderKeyAccountConflict, got {err:?}" + ); + + let conn = persister.lock_conn_for_test(); + assert_eq!( + account_row_count(&conn, &w), + 0, + "neither entry may be written" + ); +} + +/// A BLS provider account cannot change xpub across flushes. +#[test] +fn conflicting_provider_xpub_against_persisted_row_is_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD6); + ensure_wallet_meta(&persister, &w); + + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x36)], + ..Default::default() + }, + ) + .expect("store original provider account"); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x37)], + ..Default::default() + }, + ) + .expect_err("a persisted provider account must reject a different xpub"); + let storage_error = wallet_storage_error(err); + assert!(matches!( + *storage_error, + WalletStorageError::ProviderKeyAccountConflict { + account_type: "provider_operator" + } + )); + + let conn = persister.lock_conn_for_test(); + let provider = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load original provider account"); + assert_eq!(provider.provider.len(), 1); + assert_eq!( + bls_bytes(&provider.provider[0].extended_public_key), + bls_bytes(&bls_xpub(0x36)), + "the rejected store must leave the original xpub untouched" + ); +} + +#[test] +fn conflicting_provider_account_rejects_whole_batch_before_any_write() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD8); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x3A)], + ..Default::default() + }, + ) + .expect("store original operator account"); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![ + operator_entry(0x3B), + platform_entry(0x3C), + ], + ..Default::default() + }, + ) + .expect_err("a conflicting account must reject the whole provider batch"); + assert!(matches!( + *wallet_storage_error(err), + WalletStorageError::ProviderKeyAccountConflict { + account_type: "provider_operator" + } + )); + + let conn = persister.lock_conn_for_test(); + let platform_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM account_registrations \ + WHERE wallet_id = ?1 AND account_type = 'provider_platform'", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .expect("count platform accounts"); + assert_eq!( + platform_rows, 0, + "the rejected batch must not partially write its new platform account" + ); + let provider = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load original operator account"); + assert_eq!(provider.provider.len(), 1); + assert_eq!( + bls_bytes(&provider.provider[0].extended_public_key), + bls_bytes(&bls_xpub(0x3A)), + "the rejected batch must leave the original operator xpub untouched" + ); +} + +/// Provider-only labels cannot bypass the parent guard through the ordinary +/// secp256k1 account-registration field. +#[test] +fn ecdsa_registration_path_rejects_provider_account_labels() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD7); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![operator_entry(0x38)], + ..Default::default() + }, + ) + .expect("store original provider account"); + + let ecdsa_xpub = seed_wallet(0x39) + .accounts + .all_accounts() + .first() + .expect("an ECDSA account") + .account_xpub; + let err = persister + .store( + w, + PlatformWalletChangeSet { + account_registrations: vec![AccountRegistrationEntry { + account_type: AccountType::ProviderOperatorKeys, + account_xpub: ecdsa_xpub, + }], + ..Default::default() + }, + ) + .expect_err("provider labels must be rejected by the ECDSA writer"); + assert!(matches!( + *wallet_storage_error(err), + WalletStorageError::ProviderKeyAccountEntryMismatch + )); + + let conn = persister.lock_conn_for_test(); + let provider = accounts::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load original provider account"); + assert_eq!(provider.provider.len(), 1); + assert_eq!( + bls_bytes(&provider.provider[0].extended_public_key), + bls_bytes(&bls_xpub(0x38)), + "the rejected alternate-path store must leave the original xpub untouched" + ); +} + +/// The provider write path rides the flush transaction: when a later +/// writer in the same `store()` fails, the account row and domain-seq bump roll +/// back together. Mirrors `tc_b_012`, which pins the same invariant for the +/// pool writer. +#[test] +fn partial_failure_rolls_back_provider_rows_and_bump() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD3); + ensure_wallet_meta(&persister, &w); + + // No `identities` row for this id → the token-balances writer trips an FK + // violation after the provider rows have already been written in this tx. + let mut balances = std::collections::BTreeMap::new(); + balances.insert( + ( + dpp::prelude::Identifier::from([0xEE; 32]), + dpp::prelude::Identifier::from([0xEF; 32]), + ), + 1u64, + ); + let result = persister.store( + w, + PlatformWalletChangeSet { + provider_key_account_registrations: vec![platform_entry(0x34)], + token_balances: Some(platform_wallet::changeset::TokenBalanceChangeSet { + balances, + ..Default::default() + }), + ..Default::default() + }, + ); + assert!(result.is_err(), "the FK violation must fail the flush"); + + let conn = persister.lock_conn_for_test(); + assert_eq!( + account_row_count(&conn, &w), + 0, + "the provider account row must roll back with the failed flush" + ); + assert_eq!( + versions::read_seq(&conn, &w, Domain::AccountRegistrations).expect("read_seq"), + 0, + "the domain bump must roll back with the data it marks" + ); +} + +/// Provider account-registration rows for one wallet, counted straight from SQL. +fn account_row_count(conn: &rusqlite::Connection, wallet_id: &WalletId) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1 \ + AND account_type IN ('provider_operator', 'provider_platform')", + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .expect("count provider account rows") +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rs b/packages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rs new file mode 100644 index 00000000000..98260e6e44a --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rs @@ -0,0 +1,296 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Write-path coverage for the `IdentityChangeSet.removed` tombstone +//! branch. The tombstone runs a wallet-scoped, NULL-safe +//! `UPDATE identities SET tombstoned = 1 WHERE identity_id = ?1 AND +//! wallet_id IS ?2`, mirroring the upsert's per-entry wallet cross-check. +//! These tests pin that a tombstoned identity is excluded from the +//! per-wallet `load_state` and that a foreign wallet's `removed` set +//! cannot tombstone this wallet's identity. + +mod common; + +use std::collections::{BTreeMap, BTreeSet}; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::identity::IdentityStatus; +use platform_wallet_storage::sqlite::schema::identities; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +/// Build an `IdentityEntry` parented to a specific wallet (so the upsert +/// cross-check passes and the typed `wallet_id` column is populated). +/// +/// The derivation slot is keyed off `id` because `(wallet_id, +/// identity_index)` names exactly one identity: two identities of one +/// wallet sharing a slot is the corruption `store` refuses to write. +fn entry_for(id: u8, wallet_id: [u8; 32]) -> IdentityEntry { + IdentityEntry { + id: Identifier::from([id; 32]), + balance: u64::from(id), + revision: 1, + identity_index: Some(u32::from(id)), + 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: Some(wallet_id), + dashpay_profile: None, + dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } +} + +/// An identity routed through `IdentityChangeSet.removed` is tombstoned +/// and disappears from the per-wallet `load_state` while a sibling, +/// non-removed identity survives. +#[test] +fn qa_tomb1_removed_identity_excluded_from_load() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD0); + ensure_wallet_meta(&persister, &w); + + let keep = entry_for(0x01, w); + let drop_me = entry_for(0x02, w); + let mut idents: BTreeMap = BTreeMap::new(); + idents.insert(keep.id, keep.clone()); + idents.insert(drop_me.id, drop_me.clone()); + + // First flush: insert both. + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: idents, + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + + // Second flush: tombstone drop_me. + let mut removed: BTreeSet = BTreeSet::new(); + removed.insert(drop_me.id); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: Default::default(), + removed, + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + + // The tombstoned row is still physically present (logical delete). + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(total, 2, "tombstone is a logical delete; row stays on disk"); + + let tombstoned: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1 AND tombstoned = 1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(tombstoned, 1, "exactly one identity tombstoned"); + + // load_state must skip the tombstoned identity and keep the other. + let state = identities::load_state(&conn, &w).unwrap(); + drop(conn); + let wallet_idents = state.wallet_identities.get(&w).expect("wallet bucket"); + assert_eq!( + wallet_idents.len(), + 1, + "load_state must surface only the non-tombstoned identity" + ); + let surviving_ids: Vec = wallet_idents.values().map(|m| m.identity.id()).collect(); + assert!( + surviving_ids.contains(&keep.id), + "kept identity must survive load" + ); + assert!( + !surviving_ids.contains(&drop_me.id), + "tombstoned identity must NOT appear in load" + ); +} + +/// Re-upserting a tombstoned identity clears the tombstone (the upsert +/// sets `tombstoned = 0`) — the resurrection path the writer relies on. +#[test] +fn qa_tomb2_reupsert_clears_tombstone() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD1); + ensure_wallet_meta(&persister, &w); + + let e = entry_for(0x05, w); + let mut idents: BTreeMap = BTreeMap::new(); + idents.insert(e.id, e.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: idents.clone(), + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + + let mut removed: BTreeSet = BTreeSet::new(); + removed.insert(e.id); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: Default::default(), + removed, + }), + ..Default::default() + }, + ) + .unwrap(); + + // Re-upsert resurrects. + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: idents, + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let tombstoned: i64 = conn + .query_row( + "SELECT tombstoned FROM identities WHERE identity_id = ?1", + rusqlite::params![e.id.as_slice()], + |r| r.get(0), + ) + .unwrap(); + let state = identities::load_state(&conn, &w).unwrap(); + drop(conn); + assert_eq!(tombstoned, 0, "re-upsert must clear the tombstone flag"); + assert_eq!( + state + .wallet_identities + .get(&w) + .map(|m| m.len()) + .unwrap_or(0), + 1, + "resurrected identity must reappear in load" + ); +} + +/// The tombstone UPDATE is scoped by `wallet_id`: a `removed` entry +/// naming an identity parented to a different wallet is a no-op against +/// that wallet's row (NULL-safe `wallet_id IS ?2` predicate). An +/// identity_id is globally unique to one wallet, so this is +/// defense-in-depth enforcing the isolation the data model assumes. +#[test] +fn qa_tomb3_tombstone_update_is_wallet_scoped() { + let (persister, _tmp, path) = fresh_persister(); + let wa = wid(0xE0); + let wb = wid(0xE1); + ensure_wallet_meta(&persister, &wa); + ensure_wallet_meta(&persister, &wb); + + // Identity 0x07 is parented to wallet B. + let b_ident = entry_for(0x07, wb); + let mut b_map: BTreeMap = BTreeMap::new(); + b_map.insert(b_ident.id, b_ident.clone()); + persister + .store( + wb, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: b_map, + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + + // Wallet A flushes a `removed` set naming wallet B's identity id. + let mut removed: BTreeSet = BTreeSet::new(); + removed.insert(b_ident.id); + persister + .store( + wa, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: Default::default(), + removed, + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let tombstoned: i64 = conn + .query_row( + "SELECT tombstoned FROM identities WHERE identity_id = ?1", + rusqlite::params![b_ident.id.as_slice()], + |r| r.get(0), + ) + .unwrap(); + let b_state = identities::load_state(&conn, &wb).unwrap(); + drop(conn); + + // Cross-wallet isolation: wallet A's `removed` set names wallet B's + // identity, but the wallet-scoped tombstone UPDATE leaves B's row + // untouched, so B's load still surfaces the identity. + assert_eq!( + tombstoned, 0, + "wallet-scoped tombstone: A's removed set must NOT affect B's identity" + ); + assert_eq!( + b_state + .wallet_identities + .get(&wb) + .map(|m| m.len()) + .unwrap_or(0), + 1, + "B's identity must survive A's unrelated tombstone" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode.rs b/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode.rs new file mode 100644 index 00000000000..cd4587affd6 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode.rs @@ -0,0 +1,1579 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Strict-by-default load vs. opt-in recovery, site by site. +//! +//! Each tolerable-today inconsistency gets a pair: it aborts the load under +//! [`LoadPolicy::Strict`] with a named error, and under +//! [`LoadPolicy::Recovery`] it is logged, counted on +//! [`SqlitePersister::last_load_degradation`], and the documented degraded +//! projection is served instead. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, fresh_recovery_persister, wid}; +use dashcore::hashes::Hash; +use dashcore::Txid; +use dpp::identity::accessors::IdentityGettersV0; +use platform_wallet::changeset::{ + AccountRegistrationEntry, CoreChangeSet, IdentityEntry, PersistenceError, + PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet::wallet::identity::IdentityStatus; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::{LoadSite, SqlitePersister, WalletStorageError}; +use rusqlite::params; + +/// Downcast a trait-boundary error to the storage error it wraps. +#[track_caller] +fn typed(err: PersistenceError) -> WalletStorageError { + let PersistenceError::Backend { source, .. } = err else { + panic!("expected a typed backend error, got {err:?}"); + }; + *source + .downcast::() + .expect("backend source must be a WalletStorageError") +} + +/// Assert exactly `expected` tolerated events were counted at `site`, and +/// that no other site fired. +#[track_caller] +fn assert_only_site(persister: &SqlitePersister, site: LoadSite, expected: u32) { + let degradation = persister.last_load_degradation(); + assert!(degradation.degraded, "load must report itself degraded"); + assert_eq!( + degradation.by_site.get(&site).copied(), + Some(expected), + "per-site count for {site}: {:?}", + degradation.by_site + ); + assert_eq!( + degradation.by_site.len(), + 1, + "no other site may fire: {:?}", + degradation.by_site + ); + assert_eq!(degradation.total, expected); +} + +/// Seed a `core_sync_state` row, then overwrite its chain lock with bytes +/// that are not a `ChainLock`. +fn seed_corrupt_chain_lock(persister: &SqlitePersister, wallet: &WalletId) { + ensure_wallet_meta(persister, wallet); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(11), + last_processed_height: Some(11), + ..Default::default() + }); + persister.store(*wallet, cs).expect("seed core sync state"); + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE core_sync_state SET last_applied_chain_lock = ?1 WHERE wallet_id = ?2", + params![&[0xFFu8; 5][..], wallet.as_slice()], + ) + .expect("plant corrupt chain lock"); +} + +/// Seed one blob-bearing `core_transactions` row, then drift its typed +/// `height` column away from the height inside the blob. +fn seed_drifted_transaction(persister: &SqlitePersister, wallet: &WalletId) { + ensure_wallet_meta(persister, wallet); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + records: vec![confirmed_record()], + ..Default::default() + }); + persister.store(*wallet, cs).expect("seed transaction"); + let conn = persister.lock_conn_for_test(); + let updated = conn + .execute( + "UPDATE core_transactions SET height = 999 WHERE wallet_id = ?1", + params![wallet.as_slice()], + ) + .expect("drift typed height"); + assert_eq!(updated, 1, "seed must have written exactly one row"); +} + +fn drifted_txid() -> Txid { + Txid::from_byte_array([0x7Au8; 32]) +} + +/// A record whose blob says height 300, so a drifted typed column is +/// unambiguous. +fn confirmed_record() -> key_wallet::managed_account::transaction_record::TransactionRecord { + use dashcore::{BlockHash, Transaction}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + let mut record = TransactionRecord::new( + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 300, + BlockHash::from_byte_array([0x21u8; 32]), + 1_735_689_600, + )), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 100, + ); + record.txid = drifted_txid(); + record +} + +/// Register a deterministic keyless wallet and return its BIP44 external +/// index-zero address. +fn seed_registered_wallet( + persister: &SqlitePersister, + wallet_id: WalletId, + seed: u8, +) -> dashcore::Address { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::wallet::Wallet; + + let wallet = Wallet::from_seed_bytes( + [seed; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("test wallet"); + let info = ManagedWalletInfo::from_wallet(&wallet, 1); + let address = info.accounts.standard_bip44_accounts[&0] + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.is_external()) + .and_then(|pool| pool.address_at_index(0)) + .expect("BIP44 external address"); + let account_registrations = wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(); + persister + .store( + wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + account_registrations, + ..Default::default() + }, + ) + .expect("register wallet"); + address +} + +fn identity_entry(wallet_id: WalletId, id: u8, index: u32) -> IdentityEntry { + IdentityEntry { + id: dpp::prelude::Identifier::from([id; 32]), + balance: u64::from(id), + revision: 1, + identity_index: Some(index), + 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: Some(wallet_id), + dashpay_profile: None, + dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } +} + +fn seed_identity_index_collision(persister: &SqlitePersister, wallet_id: WalletId) { + use platform_wallet_storage::sqlite::schema::blob; + + ensure_wallet_meta(persister, &wallet_id); + let conn = persister.lock_conn_for_test(); + for id in [0xEE, 0x11] { + let entry = identity_entry(wallet_id, id, 7); + conn.execute( + "INSERT INTO identities \ + (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 7, ?3, 0)", + params![ + entry.id.as_slice(), + wallet_id.as_slice(), + blob::encode(&entry).unwrap() + ], + ) + .expect("plant duplicate identity index"); + } + // Skew the planner's statistics so it prefers the cheap non-covering + // `idx_identities_wallet` over the covering `(wallet_id, identity_id)` + // index — that is what makes an unordered read return insertion order + // instead of ascending `identity_id`. + // + // This is a SIMULATION, and worth being honest about: production never + // runs `ANALYZE`, so it has no `sqlite_stat1`, the covering index always + // wins, and the reader's explicit `ORDER BY` therefore changes nothing in + // production *today*. The clause is a guarantee against a future planner, + // not a live bug fix, and this fixture exists to prove the guarantee is + // load-bearing rather than decorative. + conn.execute_batch( + "ANALYZE; \ + UPDATE sqlite_stat1 SET stat = '1000000 1000000' \ + WHERE idx = 'idx_identities_wallet_identity'; \ + UPDATE sqlite_stat1 SET stat = '2 2' WHERE idx = 'idx_identities_wallet'; \ + ANALYZE sqlite_schema;", + ) + .expect("make the unordered query prefer insertion order"); + + // Assert the simulation actually took. Sensitivity rests on planner + // behaviour, so a future SQLite that ignores the skew would make the + // collision test pass for a reason unrelated to the `ORDER BY` it exists + // to protect — silently, and looking exactly like success. Fail here + // instead, at the fixture, where the message can say why. + let unordered_first: Vec = conn + .query_row( + "SELECT identity_id FROM identities WHERE wallet_id IS ?1 LIMIT 1", + params![wallet_id.as_slice()], + |row| row.get(0), + ) + .expect("read back the first unordered row"); + assert_eq!( + unordered_first, + vec![0xEEu8; 32], + "fixture no longer simulates an unordered read: this query must yield \ + insertion order (0xEE first), or the collision test proves nothing \ + about the reader's ORDER BY. Re-skew sqlite_stat1 for the current \ + planner." + ); +} + +fn seed_asset_lock_status_drift( + persister: &SqlitePersister, + wallet_id: WalletId, +) -> dashcore::OutPoint { + use dashcore::{OutPoint, Transaction}; + 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}; + + ensure_wallet_meta(persister, &wallet_id); + let outpoint = OutPoint::new(Txid::from_byte_array([0xA5; 32]), 0); + let entry = AssetLockEntry { + out_point: outpoint, + 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: 1_000, + status: AssetLockStatus::Consumed, + proof: None, + }; + 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', 0, 0, 1000, ?3)", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&outpoint).unwrap(), + asset_locks::encode_entry_for_test(&entry).unwrap() + ], + ) + .expect("plant asset-lock status drift"); + + let live_outpoint = OutPoint::new(Txid::from_byte_array([0xB6; 32]), 0); + let live_entry = AssetLockEntry { + out_point: live_outpoint, + status: AssetLockStatus::Built, + ..entry + }; + conn.execute( + "INSERT INTO asset_locks \ + (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ + VALUES (?1, ?2, 'built', 0, 0, 1000, ?3)", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&live_outpoint).unwrap(), + asset_locks::encode_entry_for_test(&live_entry).unwrap() + ], + ) + .expect("plant live asset-lock control"); + live_outpoint +} + +#[test] +fn account_registration_drift_is_strictly_fatal_and_recovery_drops_only_that_row() { + let wallet = wid(0x3E); + let outpoint = dashcore::OutPoint::new(Txid::from_byte_array([0x3E; 32]), 0); + let seed = |persister: &SqlitePersister| { + let address = seed_registered_wallet(persister, wallet, 0x3E); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, 0, ?2, 0)", + params![wallet.as_slice(), address.script_pubkey().as_bytes()], + ) + .expect("plant BIP44 ownership row"); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 7000, ?3, 0)", + params![ + wallet.as_slice(), + platform_wallet_storage::sqlite::schema::blob::encode_outpoint(&outpoint).unwrap(), + address.script_pubkey().as_bytes() + ], + ) + .expect("plant BIP44 UTXO"); + assert_eq!( + conn.execute( + "UPDATE account_registrations SET account_index = 99 \ + WHERE wallet_id = ?1 AND account_type = 'standard_bip44'", + params![wallet.as_slice()], + ) + .unwrap(), + 1 + ); + }; + + let (strict, _tmp, _path) = fresh_persister(); + seed(&strict); + let err = typed( + strict + .load() + .expect_err("strict must reject registration drift"), + ); + assert!(matches!( + err, + WalletStorageError::AccountRegistrationEntryMismatch + )); + + let (recovery, _tmp, _path) = fresh_recovery_persister(seed); + let state = recovery + .load() + .expect("recovery drops only the drifted row"); + let loaded = &state.wallets[&wallet]; + assert!(loaded.wallet.accounts.standard_bip44_accounts.is_empty()); + assert!(loaded + .wallet + .accounts + .standard_bip32_accounts + .contains_key(&0)); + assert!(loaded.wallet.accounts.coinjoin_accounts.contains_key(&0)); + let fallback = &loaded.wallet_info.accounts.standard_bip32_accounts[&0]; + assert!(fallback.utxos.contains_key(&outpoint)); + assert_eq!(fallback.balance.total(), 7_000); + assert_eq!(loaded.wallet_info.balance.total(), 7_000); + let degradation = recovery.last_load_degradation(); + assert_eq!( + degradation.by_site.get(&LoadSite::AccountRegistrationDrift), + Some(&1) + ); + assert_eq!( + degradation.by_site.get(&LoadSite::OrphanedUtxoOwner), + Some(&2) + ); + assert_eq!( + degradation.by_site.get(&LoadSite::UnresolvedUtxoAddress), + Some(&1) + ); + assert_eq!(degradation.by_site.len(), 3); + assert_eq!(degradation.total, 4); +} + +#[test] +fn consumed_blob_status_withdraws_asset_lock_from_recovery_live_set() { + let wallet = wid(0x3F); + let (strict, _tmp, _path) = fresh_persister(); + seed_asset_lock_status_drift(&strict, wallet); + let err = typed(strict.load().expect_err("strict must reject status drift")); + assert!(matches!( + err, + WalletStorageError::AssetLockStatusMismatch { .. } + )); + + let live_outpoint = dashcore::OutPoint::new(Txid::from_byte_array([0xB6; 32]), 0); + let (recovery, _tmp, _path) = fresh_recovery_persister(|strict| { + seed_asset_lock_status_drift(strict, wallet); + }); + let state = recovery + .load() + .expect("recovery lets the blob withdraw the lock"); + let locks = &state.wallets[&wallet].unused_asset_locks; + assert_eq!(locks.len(), 1); + assert_eq!(locks[&0].len(), 1); + assert!(locks[&0].contains_key(&live_outpoint)); + let drifted_outpoint = dashcore::OutPoint::new(Txid::from_byte_array([0xA5; 32]), 0); + assert!(!locks[&0].contains_key(&drifted_outpoint)); + assert_only_site(&recovery, LoadSite::AssetLockStatusDrift, 1); +} + +// -- previously uncovered load sites ----------------------------------- + +#[test] +fn identity_index_collision_is_strictly_fatal_and_recovery_loses_no_identity() { + let wallet = wid(0x40); + let (strict, _tmp, _path) = fresh_persister(); + seed_identity_index_collision(&strict, wallet); + let err = typed(strict.load().expect_err("strict must reject the collision")); + assert!(matches!( + err, + WalletStorageError::IdentityIndexConflict { .. } + )); + + let (recovery, _tmp, _path) = + fresh_recovery_persister(|strict| seed_identity_index_collision(strict, wallet)); + let state = recovery.load().expect("recovery must select one identity"); + let manager = &state.wallets[&wallet].identity_manager; + let identities = &manager.wallet_identities[&wallet]; + // Only one identity can hold slot 7 — that part is unavoidable. + assert_eq!(identities.len(), 1); + assert_eq!( + identities[&7].identity.id(), + dpp::prelude::Identifier::from([0xEE; 32]), + "ascending identity_id order makes the lexicographically higher row the winner" + ); + // The load must not LOSE the other one. Nothing on disk says which + // identity truly owns index 7, so the displaced row is parked in the + // no-slot bucket rather than dropped. Recovery makes the persister + // read-only, so an identity dropped here could never be re-persisted. + let displaced = dpp::prelude::Identifier::from([0x11; 32]); + assert!( + manager.out_of_wallet_identities.contains_key(&displaced), + "the displaced identity must survive the load, not vanish; got keys {:?}", + manager.out_of_wallet_identities.keys().collect::>() + ); + assert_eq!( + manager.out_of_wallet_identities[&displaced].identity.id(), + displaced + ); + assert_only_site(&recovery, LoadSite::IdentityIndexCollision, 1); +} + +#[test] +fn orphaned_utxo_owner_is_counted_by_recovery_load() { + let wallet = wid(0x41); + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + let address = seed_registered_wallet(strict, wallet, 0x41); + let conn = strict.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 999, 0, 0, 0, ?2, 1)", + params![wallet.as_slice(), address.script_pubkey().as_bytes()], + ) + .expect("plant orphaned pool owner"); + }); + + persister.load().expect("recovery load"); + assert_only_site(&persister, LoadSite::OrphanedUtxoOwner, 1); +} + +#[test] +fn unresolved_and_undecodable_addresses_are_counted_separately() { + let wallet = wid(0x42); + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + seed_registered_wallet(strict, wallet, 0x42); + let conn = strict.lock_conn_for_test(); + let bad_script = [0x6A_u8]; + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, 0, ?2, 1)", + params![wallet.as_slice(), bad_script.as_slice()], + ) + .expect("plant undecodable pool script"); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 1)", + params![wallet.as_slice(), &[0xFF_u8; 36], bad_script.as_slice()], + ) + .expect("plant undecodable UTXO script"); + + for index in 0_u32..900 { + let mut hash = [0_u8; 20]; + hash[..4].copy_from_slice(&index.to_le_bytes()); + let address = dashcore::Address::new( + dashcore::Network::Testnet, + dashcore::address::Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array(hash)), + ); + let mut outpoint = [0_u8; 36]; + outpoint[..4].copy_from_slice(&index.to_le_bytes()); + outpoint[4] = 1; + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 0, ?3, 1)", + params![ + wallet.as_slice(), + outpoint, + address.script_pubkey().as_bytes() + ], + ) + .expect("plant unresolved used address"); + } + }); + + persister + .load() + .expect("recovery must skip undecodable scripts"); + let degradation = persister.last_load_degradation(); + assert_eq!( + degradation.by_site.get(&LoadSite::UnresolvedUtxoAddress), + Some(&900) + ); + assert_eq!( + degradation.by_site.get(&LoadSite::UndecodableAddressScript), + Some(&2) + ); + assert_eq!(degradation.by_site.len(), 2); + assert_eq!(degradation.total, 900 + 2); +} + +#[test] +fn identity_scan_state_contradiction_is_counted_by_recovery_load() { + let wallet = wid(0x43); + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + ensure_wallet_meta(strict, &wallet); + let conn = strict.lock_conn_for_test(); + conn.execute( + "INSERT INTO identity_scan_states \ + (wallet_id, complete, probed_from, probed_through, unlocated_gap) \ + VALUES (?1, 1, 0, 9, 0)", + params![wallet.as_slice()], + ) + .expect("plant complete verdict"); + conn.execute( + "INSERT INTO identity_scan_failed_indices (wallet_id, failed_index) \ + VALUES (?1, 4)", + params![wallet.as_slice()], + ) + .expect("plant unanswered index"); + }); + + let state = persister.load().expect("recovery clamps the verdict"); + assert!(!state.wallets[&wallet].identity_manager.scan_states[&wallet].complete); + assert_only_site(&persister, LoadSite::IdentityScanStateContradiction, 1); +} + +// ── (a) chain-lock blob ───────────────────────────────────────────────── + +#[test] +fn corrupt_chain_lock_blob_is_fatal_under_strict() { + let wallet = wid(0x20); + let (persister, _tmp, _path) = fresh_persister(); + seed_corrupt_chain_lock(&persister, &wallet); + + let err = typed( + persister + .load() + .expect_err("a corrupt chain lock must abort a strict load"), + ); + assert!( + matches!(err, WalletStorageError::BincodeDecode { .. }), + "expected the upstream decode error to survive, got {err:?}" + ); + assert!( + !persister.is_degraded(), + "a failed load must not report a partial tally" + ); +} + +#[test] +fn corrupt_chain_lock_blob_is_tolerated_in_recovery() { + let wallet = wid(0x21); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| seed_corrupt_chain_lock(strict, &wallet)); + + let state = persister.load().expect("recovery must complete the load"); + let loaded = state.wallets.get(&wallet).expect("wallet must rehydrate"); + assert!( + loaded + .wallet_info + .metadata + .last_applied_chain_lock + .is_none(), + "the undecodable chain lock must be dropped, not guessed at" + ); + assert_eq!( + loaded.wallet_info.metadata.synced_height, 11, + "the rest of the sync state must survive" + ); + assert_only_site(&persister, LoadSite::ChainLockBlob, 1); +} + +// ── (c) core-transaction typed-column drift ───────────────────────────── + +#[test] +fn core_transaction_column_drift_is_fatal_under_strict() { + let wallet = wid(0x22); + let (persister, _tmp, _path) = fresh_persister(); + seed_drifted_transaction(&persister, &wallet); + + let err = typed( + persister + .load() + .expect_err("typed columns disagreeing with the blob must abort a strict load"), + ); + assert!( + matches!( + err, + WalletStorageError::CoreTransactionEntryMismatch { + typed_height: Some(999), + blob_height: Some(300), + .. + } + ), + "expected CoreTransactionEntryMismatch, got {err:?}" + ); +} + +#[test] +fn core_transaction_column_drift_is_tolerated_in_recovery() { + let wallet = wid(0x23); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| seed_drifted_transaction(strict, &wallet)); + + persister.load().expect("recovery must complete the load"); + assert_only_site(&persister, LoadSite::CoreTransactionColumnDrift, 1); +} + +#[test] +fn get_core_tx_record_never_writes() { + // The read path used to repair drifted typed columns in place. A `&self` + // read on the persistence trait must not mutate the database at all — + // this pins the row bytes across a drift read. + let wallet = wid(0x24); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| seed_drifted_transaction(strict, &wallet)); + + let before = transaction_row(&persister, &wallet); + let record = persister + .get_core_tx_record(wallet, &drifted_txid()) + .expect("recovery must still serve the point read") + .expect("blob-bearing row must return its record"); + assert_eq!( + record.height(), + Some(300), + "the blob stays authoritative for the returned record" + ); + assert_eq!( + transaction_row(&persister, &wallet), + before, + "a read must leave the row byte-identical" + ); +} + +#[test] +fn get_core_tx_record_drift_is_fatal_under_strict() { + let wallet = wid(0x25); + let (persister, _tmp, _path) = fresh_persister(); + seed_drifted_transaction(&persister, &wallet); + + let err = typed( + persister + .get_core_tx_record(wallet, &drifted_txid()) + .expect_err("a drifted row must not be served silently under strict"), + ); + assert!( + matches!(err, WalletStorageError::CoreTransactionEntryMismatch { .. }), + "expected CoreTransactionEntryMismatch, got {err:?}" + ); +} + +/// `(txid, height, record_blob)` of the wallet's single transaction row. +fn transaction_row( + persister: &SqlitePersister, + wallet: &WalletId, +) -> (Vec, Option, Vec) { + let conn = persister.lock_conn_for_test(); + conn.query_row( + "SELECT txid, height, record_blob FROM core_transactions WHERE wallet_id = ?1", + params![wallet.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("transaction row must exist") +} + +// ── (b) shielded viewing-key row ──────────────────────────────────────── + +/// One valid viewing key plus one whose blob is a byte short of the fixed +/// 96-byte width. +#[cfg(feature = "shielded")] +fn seed_corrupt_viewing_key( + persister: &SqlitePersister, + valid_wallet: &WalletId, + corrupt_wallet: &WalletId, +) { + ensure_wallet_meta(persister, valid_wallet); + ensure_wallet_meta(persister, corrupt_wallet); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO shielded_viewing_keys (wallet_id, account_index, viewing_key) \ + VALUES (?1, ?2, ?3)", + params![valid_wallet.as_slice(), 1_i64, &[0xE5_u8; 96]], + ) + .expect("insert valid viewing key"); + conn.execute( + "INSERT INTO shielded_viewing_keys (wallet_id, account_index, viewing_key) \ + VALUES (?1, ?2, ?3)", + params![corrupt_wallet.as_slice(), 2_i64, &[0xF6_u8; 95]], + ) + .expect("insert corrupt viewing key"); +} + +#[cfg(feature = "shielded")] +#[test] +fn corrupt_shielded_viewing_key_row_is_fatal_under_strict() { + let valid_wallet = wid(0x28); + let corrupt_wallet = wid(0x29); + let (persister, _tmp, _path) = fresh_persister(); + seed_corrupt_viewing_key(&persister, &valid_wallet, &corrupt_wallet); + + let err = typed( + persister + .load() + .expect_err("a corrupt viewing-key row must abort a strict load"), + ); + assert!( + matches!(err, WalletStorageError::BlobDecode { .. }), + "expected BlobDecode for the short viewing key, got {err:?}" + ); +} + +/// Relocated from `sqlite_shielded_viewing_keys.rs`: skipping one corrupt +/// row is now recovery-only behaviour, not the default. +#[cfg(feature = "shielded")] +#[test] +fn corrupt_shielded_viewing_key_row_is_skipped_in_recovery() { + use platform_wallet::wallet::shielded::SubwalletId; + let valid_wallet = wid(0x2A); + let corrupt_wallet = wid(0x2B); + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + seed_corrupt_viewing_key(strict, &valid_wallet, &corrupt_wallet) + }); + + let state = persister + .load() + .expect("one corrupt viewing key must not fail a recovery load"); + assert!(state.wallets.contains_key(&valid_wallet)); + assert!(state.wallets.contains_key(&corrupt_wallet)); + assert_eq!( + state + .shielded + .viewing_keys + .get(&SubwalletId::new(valid_wallet, 1)), + Some(&vec![0xE5; 96]) + ); + assert!(!state + .shielded + .viewing_keys + .contains_key(&SubwalletId::new(corrupt_wallet, 2))); + assert_only_site(&persister, LoadSite::ShieldedViewingKeyRow, 1); +} + +// ── (e) used address with two owning accounts ─────────────────────────── + +/// One script under two `core_address_pool` rows: the used one at account +/// index 1, an unused one at index 0. The pool reader sees only `used = 1` +/// (index 1); the per-script resolver the UTXO reader uses tie-breaks on +/// `account_index ASC` (index 0). Two sources, two answers, same address. +fn seed_conflicting_used_address_owner(persister: &SqlitePersister, wallet: &WalletId) { + ensure_wallet_meta(persister, wallet); + // A P2PKH script, so both readers can turn it back into an address. + let script = { + let address = dashcore::Address::new( + dashcore::Network::Testnet, + dashcore::address::Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array( + [0x3Cu8; 20], + )), + ); + address.script_pubkey().to_bytes() + }; + let conn = persister.lock_conn_for_test(); + for (account_index, used) in [(0_i64, 0_i64), (1, 1)] { + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', ?2, 0, 0, 0, ?3, ?4)", + params![wallet.as_slice(), account_index, script.as_slice(), used], + ) + .expect("seed pool row"); + } + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 1000, ?3, 1)", + params![wallet.as_slice(), &[0x11u8; 36][..], script.as_slice()], + ) + .expect("seed spent utxo carrying the same script"); +} + +#[test] +fn used_address_owner_conflict_is_fatal_under_strict() { + let wallet = wid(0x30); + let (persister, _tmp, _path) = fresh_persister(); + seed_conflicting_used_address_owner(&persister, &wallet); + + let err = typed( + persister + .load() + .expect_err("two owners for one used address must abort a strict load"), + ); + assert!( + matches!(err, WalletStorageError::UsedAddressOwnerConflict { .. }), + "expected UsedAddressOwnerConflict, got {err:?}" + ); +} + +#[test] +fn used_address_owner_conflict_is_tolerated_in_recovery() { + let wallet = wid(0x31); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| seed_conflicting_used_address_owner(strict, &wallet)); + + persister.load().expect("recovery must complete the load"); + assert_only_site(&persister, LoadSite::UsedAddressOwnerConflict, 1); +} + +// ── (g) unowned identity carrying a registration index ────────────────── + +/// Plant an identity with no owning wallet that nonetheless claims a +/// position within one — a row that contradicts itself. +/// +/// Written straight to SQLite: `store` refuses to create this state +/// (`WalletlessIdentityIndex`), so only a legacy database predating that +/// check can hold it — which is the state under test. `load_prekeyed` +/// buckets on the index inside `entry_blob`, so the contradiction has to +/// live in the blob, not just the column. +fn seed_self_contradictory_unowned_identity(persister: &SqlitePersister, identity_id: &[u8; 32]) { + use platform_wallet::changeset::IdentityEntry; + use platform_wallet::wallet::identity::IdentityStatus; + use platform_wallet_storage::sqlite::schema::blob; + let id = dpp::prelude::Identifier::from(*identity_id); + let entry = IdentityEntry { + id, + balance: 0, + revision: 0, + // No owning wallet, yet a position within one. + identity_index: Some(4), + 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(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + }; + let payload = blob::encode(&entry).expect("encode unowned identity entry"); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, NULL, 4, ?2, 0)", + params![id.as_slice(), payload], + ) + .expect("seed unowned identity carrying a registration index"); +} + +#[test] +fn unowned_identity_with_registration_index_is_fatal_under_strict() { + let identity_id = [0x5Au8; 32]; + let (persister, _tmp, _path) = fresh_persister(); + seed_self_contradictory_unowned_identity(&persister, &identity_id); + + let err = persister + .load_unowned_identities() + .expect_err("a self-contradictory identity row must not be served under strict"); + assert!( + matches!( + err, + WalletStorageError::UnownedIdentityHasRegistrationIndex { + identity_index: 4, + .. + } + ), + "expected UnownedIdentityHasRegistrationIndex, got {err:?}" + ); +} + +#[test] +fn unowned_identity_with_registration_index_is_tolerated_in_recovery() { + let identity_id = [0x5Bu8; 32]; + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + seed_self_contradictory_unowned_identity(strict, &identity_id) + }); + + let unowned = persister + .load_unowned_identities() + .expect("recovery must return the identity anyway"); + assert!( + unowned.contains_key(&dpp::prelude::Identifier::from(identity_id)), + "the identity must still be reachable for rescue" + ); + assert_only_site(&persister, LoadSite::UnownedIdentityHasRegistrationIndex, 1); +} + +#[test] +fn load_unowned_identities_adds_to_the_load_snapshot_instead_of_replacing_it() { + let wallet = wid(0x2C); + let identity_id = [0x5Cu8; 32]; + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + seed_corrupt_chain_lock(strict, &wallet); + seed_self_contradictory_unowned_identity(strict, &identity_id); + }); + + persister.load().expect("recovery load"); + persister + .load_unowned_identities() + .expect("recovery unowned read"); + + let degradation = persister.last_load_degradation(); + assert_eq!( + degradation.by_site.get(&LoadSite::ChainLockBlob).copied(), + Some(1), + "the load()'s own tally must survive: {:?}", + degradation.by_site + ); + assert_eq!( + degradation + .by_site + .get(&LoadSite::UnownedIdentityHasRegistrationIndex) + .copied(), + Some(1), + "the unowned read must fold in: {:?}", + degradation.by_site + ); + assert_eq!(degradation.total, 2); +} + +// ── (h) rows in tables `load()` has no reader for ─────────────────────── + +#[test] +fn unimplemented_rows_are_counted_without_setting_degraded() { + let wallet = wid(0x33); + let identity_id = [0x6Au8; 32]; + let (persister, _tmp, _path) = fresh_persister(); + ensure_wallet_meta(&persister, &wallet); + common::ensure_identity(&persister, &identity_id, Some(&wallet)); + common::ensure_token_balance(&persister, &identity_id, &[0x6Bu8; 32]); + + persister.load().expect("clean load"); + let degradation = persister.last_load_degradation(); + assert_eq!( + degradation.unimplemented_rows, 1, + "the un-rehydrated token balance must be reported" + ); + assert!( + !degradation.degraded, + "intact-but-unread rows are not a degradation" + ); + assert_eq!(degradation.total, 0); +} + +/// The point read `get_core_tx_record` tolerates drift without tallying it +/// — one context per transaction folded into a per-load snapshot would grow +/// without bound. Pinned so the limitation stays a decision instead of +/// becoming a regression someone "fixes" in either direction. +#[test] +fn get_core_tx_record_drift_leaves_the_load_snapshot_alone() { + let wallet = wid(0x2E); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| seed_drifted_transaction(strict, &wallet)); + + persister + .get_core_tx_record(wallet, &drifted_txid()) + .expect("recovery must serve the point read") + .expect("blob-bearing row must return its record"); + assert!( + !persister.is_degraded(), + "a point read must not degrade a persister that never loaded: {:?}", + persister.last_load_degradation() + ); + + persister.load().expect("recovery load"); + let after_load = persister.last_load_degradation(); + persister + .get_core_tx_record(wallet, &drifted_txid()) + .expect("recovery must serve the point read") + .expect("blob-bearing row must return its record"); + assert_eq!( + persister.last_load_degradation(), + after_load, + "a point read must not move the snapshot the last load left" + ); +} + +/// The snapshot rustdoc promises "a database restored from a backup and +/// reloaded clean reports clean". Loading the same dirty database twice +/// cannot tell replacement apart from "keep whichever was worse"; only the +/// dirty → repaired transition can. +#[test] +fn a_repaired_database_reloads_clean() { + let wallet = wid(0x2F); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| seed_corrupt_chain_lock(strict, &wallet)); + + persister.load().expect("first recovery load"); + assert_only_site(&persister, LoadSite::ChainLockBlob, 1); + + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE core_sync_state SET last_applied_chain_lock = NULL WHERE wallet_id = ?1", + params![wallet.as_slice()], + ) + .expect("repair the undecodable chain lock"); + } + + persister.load().expect("reload after repair"); + let degradation = persister.last_load_degradation(); + assert!( + !persister.is_degraded(), + "a repaired database must report clean: {degradation:?}" + ); + assert!(degradation.by_site.is_empty()); + assert_eq!(degradation.total, 0); +} + +/// A failed `load()` leaves no STALE verdict: the snapshot is cleared before +/// the walk starts, so a caller can never read a previous load's tally and +/// take it for this one's. +/// +/// This used to be pinned with "tolerate a few sites, then meet an oversize +/// blob". That shape is unreachable by design: a per-row failure now costs +/// its own wallet and no longer aborts the file, so the lever here is a +/// FILE-level failure instead — a `wallets.wallet_id` of the wrong width +/// makes `wallets::list_ids` fail before the per-wallet loop begins, which is +/// correct, because a wallet index that cannot be read leaves nothing to +/// isolate. Do not restore the old shape. +#[test] +fn a_failed_load_leaves_no_stale_verdict() { + let tolerated = wid(0x01); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| seed_corrupt_chain_lock(strict, &tolerated)); + + persister + .load() + .expect("an undecodable chain lock is tolerable under Recovery"); + let first = persister.last_load_degradation(); + assert!( + first.degraded, + "the first load must leave a verdict for the second to have to clear: {first:?}" + ); + + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) \ + VALUES (X'0102', 'testnet', 0)", + [], + ) + .expect("plant a wallet id that is not 32 bytes"); + } + + let err = typed( + persister + .load() + .expect_err("an unreadable wallet index is file-fatal in either policy"), + ); + assert!( + matches!(err, WalletStorageError::InvalidWalletIdLength { .. }), + "expected InvalidWalletIdLength, got {err:?}" + ); + assert_eq!( + persister.last_load_degradation(), + platform_wallet_storage::LoadDegradation::default(), + "a failed load must leave no verdict at all, stale or partial" + ); +} + +// ── flag semantics ────────────────────────────────────────────────────── + +#[test] +fn degraded_flag_is_false_on_a_clean_load() { + let wallet = wid(0x26); + let (persister, _tmp, _path) = fresh_persister(); + ensure_wallet_meta(&persister, &wallet); + + persister.load().expect("clean load"); + assert!(!persister.is_degraded()); + assert_eq!(persister.last_load_degradation().total, 0); + assert!(persister.last_load_degradation().by_site.is_empty()); +} + +#[test] +fn degraded_counts_are_per_load_not_cumulative() { + let wallet = wid(0x27); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| seed_corrupt_chain_lock(strict, &wallet)); + + persister.load().expect("first recovery load"); + assert_only_site(&persister, LoadSite::ChainLockBlob, 1); + persister.load().expect("second recovery load"); + assert_only_site( + &persister, + LoadSite::ChainLockBlob, + 1, // replaced, not summed — otherwise a repaired DB could never read clean + ); +} + +/// Seed one healthy wallet and one whose single UNSPENT UTXO carries a bare +/// `OP_RETURN` — a valid script that is not an address. That decode is +/// deliberately fail-hard (it is the balance source), so the sick wallet is +/// genuinely unrehydratable; the question is only who else it takes with it. +fn seed_healthy_and_sick_wallets(strict: &SqlitePersister, healthy: WalletId, sick: WalletId) { + seed_registered_wallet(strict, healthy, 0x51); + seed_registered_wallet(strict, sick, 0x52); + let conn = strict.lock_conn_for_test(); + // The outpoint must be genuinely encoded, or the row fails its bincode + // decode first and the fixture never reaches the script at all. + let outpoint = dashcore::OutPoint::new(Txid::from_byte_array([0x52; 32]), 0); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 5000, ?3, 0)", + params![ + sick.as_slice(), + platform_wallet_storage::sqlite::schema::blob::encode_outpoint(&outpoint).unwrap(), + [0x6A_u8].as_slice() + ], + ) + .expect("plant an unspent utxo whose script is not an address"); +} + +#[test] +fn unknown_pool_account_labels_fail_strict_and_isolate_the_wallet_in_recovery() { + // Cover the used-pool reader and both spent/unspent UTXO owner lookups. + for spent in [None, Some(false), Some(true)] { + let healthy = wid(0x61); + let sick = wid(0x62); + let (recovery, _tmp, path) = fresh_recovery_persister(|strict| { + seed_registered_wallet(strict, healthy, 0x61); + let address = seed_registered_wallet(strict, sick, 0x62); + let script = address.script_pubkey(); + let conn = strict.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'unknown_account', 0, 0, 0, 0, ?2, ?3)", + params![sick.as_slice(), script.as_bytes(), spent.is_none()], + ) + .unwrap(); + if let Some(spent) = spent { + let outpoint = dashcore::OutPoint::new(Txid::from_byte_array([0x62; 32]), 0); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 5000, ?3, ?4)", + params![ + sick.as_slice(), + platform_wallet_storage::sqlite::schema::blob::encode_outpoint(&outpoint) + .unwrap(), + script.as_bytes(), + spent, + ], + ) + .unwrap(); + } + }); + drop(recovery); + + let strict = + SqlitePersister::open(platform_wallet_storage::SqlitePersisterConfig::new(&path)) + .unwrap(); + let err = typed( + strict + .load() + .expect_err("unknown pool owner must fail Strict"), + ); + assert!( + matches!( + err, + WalletStorageError::BlobDecode { + reason: "core_address_pool.account_type is unknown" + } + ), + "unexpected error for {spent:?}: {err:?}" + ); + drop(strict); + + let recovery = SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(&path) + .with_load_policy(platform_wallet_storage::LoadPolicy::Recovery), + ) + .unwrap(); + let state = recovery.load().expect("healthy sibling must survive"); + assert!(state.wallets.contains_key(&healthy)); + assert!(!state.wallets.contains_key(&sick)); + assert_only_site(&recovery, LoadSite::WalletRehydration, 1); + let degradation = recovery.last_load_degradation(); + assert_eq!(degradation.wallets_degraded.len(), 1); + assert_eq!( + degradation.wallets_degraded.get(&sick), + Some(&"blob_decode") + ); + } +} + +/// Recovery is a per-WALLET verdict, not a per-file one: one wallet that +/// cannot be rebuilt degrades itself and nothing else. The loss is +/// ATTRIBUTED, not merely counted — a wallet missing from the result is +/// otherwise indistinguishable from a wallet that never existed. +#[test] +fn one_wallets_undecodable_unspent_script_does_not_take_its_sibling_down() { + let healthy = wid(0x51); + let sick = wid(0x52); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| seed_healthy_and_sick_wallets(strict, healthy, sick)); + + let state = persister + .load() + .expect("one damaged wallet must not fail the whole file under Recovery"); + assert!( + state.wallets.contains_key(&healthy), + "the healthy wallet must rehydrate" + ); + assert!( + !state.wallets.contains_key(&sick), + "the damaged wallet must not be served half-rebuilt" + ); + + let degradation = persister.last_load_degradation(); + assert_eq!( + degradation.by_site.get(&LoadSite::WalletRehydration), + Some(&1), + "one wallet, one degradation: {:?}", + degradation.by_site + ); + assert_eq!( + degradation.wallets_degraded.get(&sick).copied(), + Some("address_decode"), + "the dropped wallet must name itself and its cause: {:?}", + degradation.wallets_degraded + ); + assert!( + !degradation.wallets_degraded.contains_key(&healthy), + "a wallet that loaded must not be reported degraded" + ); +} + +/// The boundary changes WHERE a failure stops, never WHAT it is. Under +/// `Strict` the same fixture still aborts, and with the original typed cause +/// rather than the boundary's own wrapper, so a caller matching on the cause +/// keeps matching. +#[test] +fn the_isolation_boundary_reports_the_original_cause_under_strict() { + let healthy = wid(0x51); + let sick = wid(0x52); + let (recovery, _tmp, path) = + fresh_recovery_persister(|strict| seed_healthy_and_sick_wallets(strict, healthy, sick)); + drop(recovery); + + let strict = SqlitePersister::open(platform_wallet_storage::SqlitePersisterConfig::new(&path)) + .expect("reopen strict"); + let err = typed( + strict + .load() + .expect_err("strict must still refuse a file it cannot fully rebuild"), + ); + assert!( + matches!(err, WalletStorageError::AddressDecode { .. }), + "strict must surface the original cause, not the boundary wrapper: {err:?}" + ); +} + +/// An `identity_keys` entry with a distinguishable public key. +fn identity_key_entry( + identity_id: dpp::prelude::Identifier, + key_id: u32, + byte: u8, +) -> platform_wallet::changeset::IdentityKeyEntry { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + + platform_wallet::changeset::IdentityKeyEntry { + identity_id, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + } +} + +/// One identity carrying two keys, both written through the production +/// writer so the rows are exactly what a real save produces. +fn seed_identity_with_two_keys( + strict: &SqlitePersister, + wallet: WalletId, +) -> dpp::prelude::Identifier { + use platform_wallet::changeset::{IdentityChangeSet, IdentityKeysChangeSet}; + + ensure_wallet_meta(strict, &wallet); + let entry = identity_entry(wallet, 0x5A, 0); + let identity_id = entry.id; + let mut identities = IdentityChangeSet::default(); + identities.identities.insert(identity_id, entry); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts + .insert((identity_id, 0), identity_key_entry(identity_id, 0, 0xA1)); + keys.upserts + .insert((identity_id, 1), identity_key_entry(identity_id, 1, 0xB2)); + + let mut cs = PlatformWalletChangeSet::default(); + cs.identities = Some(identities); + cs.identity_keys = Some(keys); + strict + .store(wallet, cs) + .expect("seed identity and its keys"); + identity_id +} + +/// A single unreadable `identity_keys` row costs THAT ROW. Keys carry no +/// funds, so the whole-wallet granularity that balance-bearing rows demand +/// would be needless damage here: the wallet, its identity, and its other +/// keys all come back. +#[test] +fn a_corrupt_identity_key_row_costs_the_row_not_the_wallet() { + let wallet = wid(0x53); + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + let identity_id = seed_identity_with_two_keys(strict, wallet); + let conn = strict.lock_conn_for_test(); + // The indexed hash no longer matches the blob it was selected by. + conn.execute( + "UPDATE identity_keys SET public_key_hash = ?1 \ + WHERE identity_id = ?2 AND key_id = 1", + params![[0x00_u8; 20].as_slice(), identity_id.as_slice()], + ) + .expect("plant a key row whose hash contradicts its blob"); + }); + + let state = persister.load().expect("one bad key row must not be fatal"); + let start = state + .wallets + .get(&wallet) + .expect("the wallet must survive a single unreadable key row"); + let identity = &start.identity_manager.wallet_identities[&wallet][&0]; + let key_ids: Vec = identity.identity.public_keys().keys().copied().collect(); + assert_eq!( + key_ids, + vec![0], + "the readable key must survive and the unreadable one must not" + ); + assert_only_site(&persister, LoadSite::IdentityKeyRow, 1); +} + +/// A single unreadable `contacts` row costs THAT ROW. Contacts carry no +/// funds either, so the wallet, its identity and its other contacts survive +/// a torn request blob. +#[test] +fn a_torn_contact_blob_costs_the_contact_not_the_wallet() { + use platform_wallet::changeset::{ + ContactChangeSet, ContactRequestEntry, SentContactRequestKey, + }; + use platform_wallet::wallet::identity::ContactRequest; + + let wallet = wid(0x54); + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + let identity_id = seed_identity_with_two_keys(strict, wallet); + let request = |recipient: dpp::prelude::Identifier| ContactRequestEntry { + request: ContactRequest { + sender_id: identity_id, + 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 readable = dpp::prelude::Identifier::from([0x71; 32]); + let torn = dpp::prelude::Identifier::from([0x72; 32]); + let mut contacts = ContactChangeSet::default(); + for recipient in [readable, torn] { + contacts.sent_requests.insert( + SentContactRequestKey { + owner_id: identity_id, + recipient_id: recipient, + }, + request(recipient), + ); + } + let mut cs = PlatformWalletChangeSet::default(); + cs.contacts = Some(contacts); + strict.store(wallet, cs).expect("seed two sent requests"); + + let conn = strict.lock_conn_for_test(); + conn.execute( + "UPDATE contacts SET outgoing_request = X'00' WHERE contact_id = ?1", + params![torn.as_slice()], + ) + .expect("tear one request blob"); + }); + + let state = persister + .load() + .expect("one torn contact must not be fatal"); + let start = state + .wallets + .get(&wallet) + .expect("the wallet must survive a single torn contact blob"); + let identity = &start.identity_manager.wallet_identities[&wallet][&0]; + let recipients: Vec<[u8; 32]> = identity + .dashpay() + .sent_contact_requests() + .keys() + .map(|id| id.to_buffer()) + .collect(); + assert_eq!( + recipients, + vec![[0x71_u8; 32]], + "the readable request must survive and the torn one must not" + ); + assert_only_site(&persister, LoadSite::ContactRow, 1); +} + +/// `platform_addresses` rows carry `balance`, so a row that cannot be read +/// costs its whole WALLET, never just itself: skipping the row would report +/// a smaller balance with no signal. The wallet is dropped and attributed; +/// its healthy sibling in the same file still loads. +#[test] +fn an_unreadable_platform_address_row_costs_its_wallet_not_the_file() { + let healthy = wid(0x55); + let sick = wid(0x56); + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + ensure_wallet_meta(strict, &healthy); + ensure_wallet_meta(strict, &sick); + let conn = strict.lock_conn_for_test(); + conn.execute( + "INSERT INTO platform_addresses \ + (wallet_id, account_index, address_index, address, balance, nonce) \ + VALUES (?1, 0, 0, ?2, 0, 0)", + params![sick.as_slice(), [0xAB_u8; 19].as_slice()], + ) + .expect("plant an address that is not 20 bytes"); + }); + + let state = persister + .load() + .expect("one wallet's unreadable address row must not fail the file"); + assert!( + !state.platform_addresses.contains_key(&sick), + "a wallet whose address rows cannot be read must not be served a partial set" + ); + assert!( + !state.wallets.contains_key(&sick), + "and it must not be rebuilt from its other tables either" + ); + let degradation = persister.last_load_degradation(); + assert_eq!( + degradation.wallets_degraded.get(&sick).copied(), + Some("blob_decode"), + "the wallet must name itself and its cause: {:?}", + degradation.wallets_degraded + ); + assert!( + !degradation.wallets_degraded.contains_key(&healthy), + "the healthy wallet must not be reported degraded" + ); +} + +/// An `identities` row carries the identity's CREDIT BALANCE, so it is not a +/// row this reader may skip: dropping one would quietly lower the wallet's +/// reported credits. Its failure therefore costs the whole wallet, counted +/// and attributed like any other, while a sibling wallet still loads. +/// +/// This test passes without a code change — the isolation boundary already +/// gives these sites their policy at wallet granularity. It is here to stop +/// the change it describes from being made: per-row tolerance for identities +/// would turn one dropped wallet into a wallet with silently missing credits, +/// and this assertion is what would fail. +#[test] +fn an_unreadable_identity_row_costs_its_wallet_not_just_the_identity() { + use platform_wallet_storage::sqlite::schema::blob; + + let healthy = wid(0x57); + let sick = wid(0x58); + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + ensure_wallet_meta(strict, &healthy); + ensure_wallet_meta(strict, &sick); + let conn = strict.lock_conn_for_test(); + // The blob names a different identity than the column it is filed + // under, which is corruption the reader cannot resolve. + let entry = identity_entry(sick, 0x99, 0); + conn.execute( + "INSERT INTO identities \ + (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![ + [0x58_u8; 32].as_slice(), + sick.as_slice(), + blob::encode(&entry).unwrap() + ], + ) + .expect("plant an identity whose blob contradicts its column"); + }); + + let state = persister + .load() + .expect("one wallet's unreadable identity row must not fail the file"); + assert!( + state.wallets.contains_key(&healthy), + "the healthy wallet must rehydrate" + ); + assert!( + !state.wallets.contains_key(&sick), + "the wallet owning the unreadable identity must be dropped whole, \ + not served with one identity's credits missing" + ); + let degradation = persister.last_load_degradation(); + assert_eq!( + degradation.wallets_degraded.get(&sick).copied(), + Some("identity_entry_id_mismatch"), + "the loss must be attributed to the wallet and its cause: {:?}", + degradation.wallets_degraded + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode_write_block.rs b/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode_write_block.rs new file mode 100644 index 00000000000..6f2798d4f49 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode_write_block.rs @@ -0,0 +1,285 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `LoadPolicy::Recovery` makes the persister read-only. +//! +//! A recovery load hands back a degraded projection of the rows, so every +//! mutating entry point must refuse rather than risk committing that +//! projection over the good data. Reads, and `backup_to` (a read of the +//! source), stay available. + +mod common; + +use std::fs; + +use common::{fresh_recovery_persister, wid, LoadPolicy, SqlitePersister, SqlitePersisterConfig}; +use platform_wallet::changeset::{ + CoreChangeSet, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::masternode::{TrackedMasternode, TrackedMasternodeSnapshot}; +use platform_wallet_storage::{KvError, KvStore, ObjectId, RetentionPolicy, WalletStorageError}; + +fn core_changeset() -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(9), + last_processed_height: Some(9), + ..Default::default() + }); + cs +} + +/// Assert a `PersistenceError` from the trait boundary carries +/// `ReadOnlyRecoveryMode` naming `operation`. +#[track_caller] +fn assert_blocked(err: PersistenceError, operation: &str) { + let PersistenceError::Backend { source, .. } = err else { + panic!("expected a typed backend error, got {err:?}"); + }; + match source.downcast_ref::() { + Some(WalletStorageError::ReadOnlyRecoveryMode { operation: got }) => { + assert_eq!(*got, operation, "blocked operation name"); + } + other => panic!("expected ReadOnlyRecoveryMode, got {other:?}"), + } +} + +fn tracked_masternode(byte: u8) -> TrackedMasternode { + TrackedMasternode { + pro_tx_hash: [byte; 32], + label: Some("rescue".to_string()), + added_at: byte as u64, + snapshot: TrackedMasternodeSnapshot::default(), + } +} + +#[test] +fn store_is_blocked_in_recovery() { + let wallet = wid(0x10); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| common::ensure_wallet_meta(strict, &wallet)); + let err = persister + .store(wallet, core_changeset()) + .expect_err("store must be refused in recovery mode"); + assert_blocked(err, "store"); +} + +#[test] +fn store_in_recovery_leaves_buffer_empty() { + let wallet = wid(0x11); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| common::ensure_wallet_meta(strict, &wallet)); + let _ = persister.store(wallet, core_changeset()); + assert!( + !persister.buffer_has_changeset_for_test(&wallet), + "a refused store must not stage a changeset that a later write could drain" + ); +} + +#[test] +fn flush_is_blocked_in_recovery() { + let wallet = wid(0x12); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| common::ensure_wallet_meta(strict, &wallet)); + let err = persister + .flush(wallet) + .expect_err("flush must be refused in recovery mode"); + assert_blocked(err, "flush"); +} + +#[test] +fn commit_writes_is_blocked_in_recovery() { + let wallet = wid(0x13); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| common::ensure_wallet_meta(strict, &wallet)); + let err = persister + .commit_writes() + .expect_err("commit_writes must return Err, never an empty CommitReport"); + assert_blocked(err, "commit_writes"); +} + +#[test] +fn delete_wallet_is_blocked_in_recovery() { + let wallet = wid(0x14); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| common::ensure_wallet_meta(strict, &wallet)); + let err = SqlitePersister::delete_wallet(&persister, wallet) + .expect_err("delete_wallet must be refused in recovery mode"); + assert!(matches!( + err, + WalletStorageError::ReadOnlyRecoveryMode { + operation: "delete_wallet" + } + )); +} + +#[test] +fn delete_wallet_skip_backup_is_blocked_in_recovery() { + let wallet = wid(0x15); + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| common::ensure_wallet_meta(strict, &wallet)); + let err = persister + .delete_wallet_skip_backup(wallet) + .expect_err("delete_wallet_skip_backup must be refused in recovery mode"); + assert!(matches!( + err, + WalletStorageError::ReadOnlyRecoveryMode { + operation: "delete_wallet" + } + )); +} + +#[test] +fn prune_backups_is_blocked_in_recovery() { + let (persister, tmp, _path) = fresh_recovery_persister(|_| {}); + let dir = tmp.path().join("backups-to-prune"); + fs::create_dir_all(&dir).expect("create backup dir"); + let err = persister + .prune_backups(&dir, RetentionPolicy::keep_last(1)) + .expect_err("prune must not shrink the rollback set during a rescue"); + assert!(matches!( + err, + WalletStorageError::ReadOnlyRecoveryMode { + operation: "prune_backups" + } + )); +} + +#[test] +fn persist_tracked_masternodes_is_blocked_in_recovery() { + let (persister, _tmp, _path) = fresh_recovery_persister(|_| {}); + let err = persister + .persist_tracked_masternodes(dashcore::Network::Testnet, &[tracked_masternode(0x21)]) + .expect_err("persist_tracked_masternodes must be refused in recovery mode"); + assert_blocked(err, "persist_tracked_masternodes"); +} + +/// The gate has to hold the DATA, not just return an error: `replace_all` +/// is whole-set, so an accepted write DELETEs the network's rows first. A +/// host mid-rescue whose in-memory list is empty or short would otherwise +/// zero the very set it opened Recovery to save. +#[test] +fn refused_persist_tracked_masternodes_leaves_the_stored_set_intact() { + let network = dashcore::Network::Testnet; + let (persister, _tmp, _path) = fresh_recovery_persister(|strict| { + strict + .persist_tracked_masternodes(network, &[tracked_masternode(0x22)]) + .expect("seed the tracked set through the strict persister"); + }); + + let _ = persister.persist_tracked_masternodes(network, &[]); + + let rows = persister + .load_tracked_masternodes(network) + .expect("reads stay available in recovery mode"); + assert_eq!( + rows.len(), + 1, + "a refused whole-set write must not delete the rows the rescue is trying to save" + ); + assert_eq!(rows[0].pro_tx_hash, [0x22u8; 32]); +} + +#[test] +fn kv_put_is_blocked_in_recovery() { + let (persister, _tmp, _path) = fresh_recovery_persister(|_| {}); + let err = persister + .put(&ObjectId::Global, "k", b"v") + .expect_err("kv put must be refused in recovery mode"); + assert!( + matches!( + err, + KvError::ReadOnlyRecoveryMode { + operation: "kv_put" + } + ), + "must be the dedicated variant, not a Sqlite catch-all: {err:?}" + ); +} + +#[test] +fn kv_delete_is_blocked_in_recovery() { + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| strict.put(&ObjectId::Global, "k", b"v").expect("seed")); + let err = persister + .delete(&ObjectId::Global, "k") + .expect_err("kv delete must be refused in recovery mode"); + assert!( + matches!( + err, + KvError::ReadOnlyRecoveryMode { + operation: "kv_delete" + } + ), + "must be the dedicated variant, not a Sqlite catch-all: {err:?}" + ); +} + +#[test] +fn kv_reads_are_allowed_in_recovery() { + let (persister, _tmp, _path) = + fresh_recovery_persister(|strict| strict.put(&ObjectId::Global, "k", b"v").expect("seed")); + assert_eq!( + persister + .get(&ObjectId::Global, "k") + .expect("get") + .as_deref(), + Some(&b"v"[..]) + ); + assert_eq!( + persister + .list_keys(&ObjectId::Global, None) + .expect("list_keys"), + vec!["k".to_string()] + ); +} + +#[test] +fn backup_to_is_allowed_in_recovery() { + let wallet = wid(0x16); + let (persister, tmp, _path) = + fresh_recovery_persister(|strict| common::ensure_wallet_meta(strict, &wallet)); + let dest = tmp.path().join("rescue-snapshot.db"); + let written = persister + .backup_to(&dest) + .expect("snapshot-before-touching must stay available in recovery mode"); + assert!(written.exists()); +} + +#[test] +fn restore_over_open_recovery_db_still_returns_already_open() { + let wallet = wid(0x17); + let (persister, tmp, path) = + fresh_recovery_persister(|strict| common::ensure_wallet_meta(strict, &wallet)); + let src = persister + .backup_to(&tmp.path().join("source.db")) + .expect("backup"); + let err = SqlitePersister::restore_from(&path, &src, Some(tmp.path())) + .expect_err("restore must refuse a destination held open by a live persister"); + assert!(matches!(err, WalletStorageError::AlreadyOpen { .. })); +} + +#[test] +fn recovery_without_auto_backup_dir_is_rejected() { + let tmp = common::secure_tempdir().expect("tempdir"); + let cfg = SqlitePersisterConfig::new(tmp.path().join("wallet.db")) + .with_load_policy(LoadPolicy::Recovery) + .with_auto_backup_dir(None); + // `SqlitePersister` is not `Debug`, so `expect_err` is unavailable. + match SqlitePersister::open(cfg) { + Err(WalletStorageError::AutoBackupDisabled { .. }) => {} + Err(other) => panic!("expected AutoBackupDisabled, got {other:?}"), + Ok(_) => panic!("recovery mode must keep a pre-migration rollback point"), + } +} + +#[test] +fn strict_persister_still_writes() { + // Guards against the block leaking into the default policy. + let wallet = wid(0x18); + let (persister, _tmp, _path) = common::fresh_persister(); + common::ensure_wallet_meta(&persister, &wallet); + persister + .store(wallet, core_changeset()) + .expect("strict mode must still accept writes"); + persister.put(&ObjectId::Global, "k", b"v").expect("kv put"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs b/packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs index e12a32b5135..56512d4004e 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs @@ -1,13 +1,15 @@ #![allow(clippy::field_reassign_with_default)] //! Cross-process exclusion for `restore_from` relies on a -//! SQLite-native EXCLUSIVE transaction against the destination file. +//! SQLite-native exclusive locking against the destination file. //! An advisory `flock(2)` would not exclude rusqlite peers; -//! `BEGIN EXCLUSIVE` does. +//! exclusive locking mode plus `BEGIN EXCLUSIVE` does. mod common; +use std::collections::HashSet; use std::path::Path; +use std::time::{Duration, Instant}; use common::{ensure_wallet_meta, fresh_persister, wid}; use platform_wallet::changeset::{ @@ -27,8 +29,46 @@ fn seed_one_row(persister: &SqlitePersister, w: &[u8; 32]) { persister.store(*w, cs).unwrap(); } +fn pad_backup_for_observable_restore(backup_path: &Path) { + let conn = rusqlite::Connection::open(backup_path).unwrap(); + conn.execute_batch("CREATE TABLE restore_padding (payload BLOB NOT NULL)") + .unwrap(); + for _ in 0..4 { + conn.execute( + "INSERT INTO restore_padding VALUES (zeroblob(?1))", + [16_i64 * 1024 * 1024], + ) + .unwrap(); + } +} + +fn wait_for_staged_copy( + dir: &Path, + existing: &HashSet, + restore: &std::thread::JoinHandle, +) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let staged_file_exists = std::fs::read_dir(dir).unwrap().any(|entry| { + let name = entry.unwrap().file_name(); + !existing.contains(&name) + && !name.to_string_lossy().ends_with("-wal") + && !name.to_string_lossy().ends_with("-shm") + }); + if staged_file_exists { + return; + } + assert!(!restore.is_finished(), "restore finished before lock probe"); + assert!( + Instant::now() < deadline, + "timed out waiting for staged copy" + ); + std::thread::yield_now(); + } +} + /// `restore_from` must hold a SQLite-native exclusive -/// lock for the full restore body. A peer rusqlite Connection (a +/// lock through validation and staging. A peer rusqlite Connection (a /// different process equivalent) opening the same DB and trying to /// `BEGIN EXCLUSIVE` while restore is in flight must conflict. /// @@ -44,7 +84,7 @@ fn seed_one_row(persister: &SqlitePersister, w: &[u8; 32]) { fn restore_takes_and_releases_native_exclusive() { let (persister, tmp, db_path) = fresh_persister(); seed_one_row(&persister, &wid(0xA1)); - let backup_dir = tempfile::tempdir().expect("backup dir"); + let backup_dir = common::secure_tempdir().expect("backup dir"); let backup_path = persister.backup_to(backup_dir.path()).unwrap(); drop(persister); @@ -71,7 +111,7 @@ fn restore_takes_and_releases_native_exclusive() { fn restore_blocks_when_peer_holds_exclusive() { let (persister, tmp, db_path) = fresh_persister(); seed_one_row(&persister, &wid(0xA2)); - let backup_dir = tempfile::tempdir().expect("backup dir"); + let backup_dir = common::secure_tempdir().expect("backup dir"); let backup_path = persister.backup_to(backup_dir.path()).unwrap(); drop(persister); @@ -102,6 +142,92 @@ fn restore_blocks_when_peer_holds_exclusive() { drop(backup_dir); } +#[test] +fn restore_excludes_plain_readers_for_restore_duration() { + let (persister, tmp, db_path) = fresh_persister(); + seed_one_row(&persister, &wid(0xA3)); + let backup_dir = common::secure_tempdir().expect("backup dir"); + let backup_path = persister.backup_to(backup_dir.path()).unwrap(); + drop(persister); + + // Keep the staged-copy phase observable long enough to probe the lock + // without a test-only production hook. + pad_backup_for_observable_restore(&backup_path); + + let existing: HashSet<_> = std::fs::read_dir(tmp.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + let restore_db = db_path.clone(); + let restore_source = backup_path.clone(); + let restore = std::thread::spawn(move || { + SqlitePersister::restore_from_skip_backup(&restore_db, &restore_source) + }); + + wait_for_staged_copy(tmp.path(), &existing, &restore); + + let reader = rusqlite::Connection::open(&db_path).unwrap(); + reader.busy_timeout(Duration::ZERO).unwrap(); + let read = reader.query_row("SELECT COUNT(*) FROM wallets", [], |row| { + row.get::<_, i64>(0) + }); + assert!( + matches!( + read, + Err(rusqlite::Error::SqliteFailure(ref error, _)) + if matches!( + error.code, + rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked + ) + ), + "plain reader must observe busy/locked while restore holds exclusion; got {read:?}" + ); + + restore.join().unwrap().expect("restore succeeds"); +} + +#[test] +fn restore_excludes_peer_creating_missing_destination() { + let (persister, tmp, _source_db_path) = fresh_persister(); + seed_one_row(&persister, &wid(0xA4)); + let backup_dir = common::secure_tempdir().expect("backup dir"); + let backup_path = persister.backup_to(backup_dir.path()).unwrap(); + drop(persister); + pad_backup_for_observable_restore(&backup_path); + + let destination = tmp.path().join("restored-missing.db"); + assert!(!destination.exists()); + let existing: HashSet<_> = std::fs::read_dir(tmp.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + let restore_destination = destination.clone(); + let restore_source = backup_path.clone(); + let restore = std::thread::spawn(move || { + SqlitePersister::restore_from_skip_backup(&restore_destination, &restore_source) + }); + + wait_for_staged_copy(tmp.path(), &existing, &restore); + let peer = rusqlite::Connection::open(&destination).unwrap(); + peer.busy_timeout(Duration::ZERO).unwrap(); + let peer_write = peer.execute_batch("CREATE TABLE peer_write (value INTEGER)"); + drop(peer); + let restore_result = restore.join().unwrap(); + + assert!( + matches!( + peer_write, + Err(rusqlite::Error::SqliteFailure(ref error, _)) + if matches!( + error.code, + rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked + ) + ), + "peer creating a missing destination must observe busy/locked; got {peer_write:?}" + ); + restore_result.expect("restore succeeds"); +} + /// flock / fs2 / fs4 must be gone from the persister. #[test] fn flock_and_fs2_traces_are_gone() { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_restore_open_path_guard.rs b/packages/rs-platform-wallet-storage/tests/sqlite_restore_open_path_guard.rs new file mode 100644 index 00000000000..90ee210b667 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_restore_open_path_guard.rs @@ -0,0 +1,49 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `restore_from` must refuse to overwrite a database that a live +//! [`SqlitePersister`] in this process is still holding open — that +//! handle's write buffer / connection would silently diverge from the +//! restored bytes. The guard mirrors `open()`'s in-process open-path +//! registry and clears once the holder drops. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet_storage::{SqlitePersister, WalletStorageError}; + +/// While a persister holds the destination open, both restore entry +/// points return [`WalletStorageError::AlreadyOpen`]; after it drops, the +/// restore succeeds. +#[test] +fn restore_refuses_an_in_process_open_destination() { + let (persister, _tmp, db_path) = fresh_persister(); + ensure_wallet_meta(&persister, &wid(0xA1)); + + // A valid wallet-storage backup to restore from. + let backup_dir = common::secure_tempdir().expect("backup dir"); + let backup_path = persister + .backup_to(backup_dir.path()) + .expect("online backup"); + + // The destination is still open in this process → refuse. + let err = SqlitePersister::restore_from_skip_backup(&db_path, &backup_path) + .expect_err("restore onto an open db must be refused"); + assert!( + matches!(err, WalletStorageError::AlreadyOpen { .. }), + "expected AlreadyOpen, got {err:?}" + ); + + // The safe-by-default entry point guards before the auto-backup too. + let err = SqlitePersister::restore_from(&db_path, &backup_path, Some(backup_dir.path())) + .expect_err("safe restore onto an open db must be refused"); + assert!( + matches!(err, WalletStorageError::AlreadyOpen { .. }), + "expected AlreadyOpen, got {err:?}" + ); + + // Once the holder drops, the open-path registry clears and the restore + // goes through. + drop(persister); + SqlitePersister::restore_from_skip_backup(&db_path, &backup_path) + .expect("restore succeeds after the holder closes"); +} 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 index 5150367c87c..34c47aa4d8d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_restore_staged_validation.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_restore_staged_validation.rs @@ -73,7 +73,7 @@ fn forward_version_rejected_destination_unchanged() { /// integrity-valid is rejected and the destination is left untouched. #[test] fn missing_schema_history_rejected_destination_unchanged() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = common::secure_tempdir().unwrap(); let fake_src = tmp.path().join("empty.db"); rusqlite::Connection::open(&fake_src).unwrap(); @@ -164,7 +164,7 @@ fn forward_version_rejected_before_staging() { .unwrap(); } - let dest_dir = tempfile::tempdir().unwrap(); + let dest_dir = common::secure_tempdir().unwrap(); let dest = dest_dir.path().join("dest.db"); fs::write(&dest, SENTINEL).unwrap(); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs new file mode 100644 index 00000000000..f295b75e859 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -0,0 +1,197 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Content-level schema-freeze guards. +//! +//! Pin the rendered migration SQL with a golden fingerprint so an +//! in-place DDL edit (which the identity-only fingerprint is documented not +//! to catch) breaks CI. Separately, assert the retired cross-branch table +//! names never appear as SQL identifiers in the writer/reader/migration/ +//! backup SQL — the drift the content-blind fingerprint cannot catch. + +use std::path::Path; + +use platform_wallet_storage::sqlite::{migrations as mig, schema::versions::Domain}; + +/// Golden `(version, name)` fingerprint of the frozen migration set. Bump +/// deliberately only when adding/removing/renaming a migration file. +const EXPECTED_ID_FINGERPRINT: &str = + "ee4e0c3efe48cab68bd25897267ae66892675fb4b2720cfc31abbbb6c8ca553f"; + +/// Golden content-level fingerprint over every migration's rendered SQL. +/// Bump it only when ADDING a migration file; a body change on an already +/// applied migration is a defect, not a golden to refresh. +const EXPECTED_SQL_FINGERPRINT: &str = + "7f74dadd8095b794d5ba5eb8aa74204666f028a4fea912c99e3f57f50b9d4bc8"; + +/// The migrations merged `v4.2-dev` already ships. Refinery keys +/// `refinery_schema_history` by version and validates an applied migration's +/// checksum against the embedded migration of the SAME version, so pointing one +/// of these versions at different DDL stops every database that applied the +/// original from opening. New work appends after the highest entry here. +const MERGED_MIGRATION_VERSIONS: &[(i32, &str)] = &[ + (1, "initial"), + (2, "address_height_pin"), + (3, "invitations"), + (4, "asset_lock_recovered_status"), + (5, "dpns_name_states"), + (6, "tracked_masternodes"), + (7, "utxo_sweep_winner_height"), +]; + +/// Historical table names: V008 renames wallet metadata, and its typed +/// conversion retains the pool tables through V010 before retiring them. +/// Only the SQL history through V008 and the versioned conversion module +/// may name these tables; live writers and readers use their replacements. +const FIRST_VERSION_AFTER_RENAME: i32 = 9; + +/// Migration files whose SQL may legitimately name a retired table: the +/// published base set and the migration that performs the rename. +const PRE_RENAME_MIGRATION_FILES: &[&str] = &[ + "V001__initial.rs", + "V002__address_height_pin.rs", + "V003__invitations.rs", + "V004__asset_lock_recovered_status.rs", + "V005__dpns_name_states.rs", + "V006__tracked_masternodes.rs", + "V007__utxo_sweep_winner_height.rs", + "V008__rehydration_base_schema.rs", +]; + +const RETIRED_SQL_NAMES: &[&str] = &[ + "wallet_metadata", + "account_address_pools", + "core_derived_addresses", +]; + +#[test] +fn domain_labels_are_live_sql_names() { + for domain in Domain::ALL { + assert!( + !RETIRED_SQL_NAMES.contains(&domain.as_str()), + "Domain::{domain:?} uses retired SQL name `{}`", + domain.as_str() + ); + } +} + +/// A version already merged to a base branch keeps the name it shipped with. +/// +/// IF THIS FAILS: a migration file was renumbered onto a version some other +/// branch already published. Give the new work the next free version instead — +/// reusing a published one is not a naming preference, it is a database that +/// stops opening. +#[test] +fn merged_migration_versions_keep_their_shipped_names() { + let embedded = mig::embedded_migrations(); + for (version, name) in MERGED_MIGRATION_VERSIONS { + let found = embedded + .iter() + .find(|(v, _)| v == version) + .unwrap_or_else(|| panic!("migration version {version} is missing from the set")); + assert_eq!( + found.1.as_str(), + *name, + "version {version} must stay `{name}`; it is owned by merged history" + ); + } +} + +/// The migration set's identity is pinned. +#[test] +fn identity_fingerprint_pinned() { + assert_eq!( + hex::encode(mig::embedded_migrations_fingerprint()), + EXPECTED_ID_FINGERPRINT, + "migration set identity changed; a file was added/removed/renamed. \ + If intentional, update EXPECTED_ID_FINGERPRINT." + ); +} + +/// The rendered migration SQL is pinned, closing the +/// content-blind gap the identity fingerprint documents. +#[test] +fn sql_fingerprint_pinned() { + assert_eq!( + hex::encode(mig::embedded_migrations_sql_fingerprint()), + EXPECTED_SQL_FINGERPRINT, + "a migration's DDL body changed. Refinery checksums rendered SQL, so \ + editing a migration that any database has already applied stops that \ + database opening, permanently. Widen a schema by APPENDING a migration. \ + Update EXPECTED_SQL_FINGERPRINT only when adding a migration file." + ); +} + +/// The retired names appear nowhere as table identifiers in migration SQL. +#[test] +fn migration_sql_has_no_retired_names() { + for (version, sql) in mig::embedded_migrations_sql_by_version() { + if version < FIRST_VERSION_AFTER_RENAME { + continue; + } + for name in RETIRED_SQL_NAMES { + for keyword in ["FROM", "INTO", "UPDATE", "TABLE", "JOIN", "ON"] { + assert!( + !sql.contains(&format!("{keyword} {name}")), + "retired table name `{name}` present in migration SQL" + ); + } + } + } +} + +/// No writer/reader/migration/backup SQL string references a +/// retired table name. `wallet_metadata` / `account_address_pools` are also +/// legitimate Rust changeset fields, so the scan flags only SQL-keyword-led +/// table usage (`FROM`/`INTO`/`UPDATE`/`TABLE`/`JOIN`/`ON `), never a +/// bare `cs.` access. +#[test] +fn no_retired_table_name_in_sql_strings() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let migrations_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations"); + let sql_keywords = ["FROM", "INTO", "UPDATE", "TABLE", "JOIN", "ON"]; + + let mut offenders = Vec::new(); + for dir in [src.clone(), migrations_dir] { + visit(&dir, &mut |path, line_no, line| { + let file = path.file_name().unwrap_or_default().to_string_lossy(); + // Versioned typed conversion is the sole live-code exception: + // it reads published state and retires it atomically after V011. + if path == src.join("sqlite/migrations/legacy_v008.rs") { + return; + } + if PRE_RENAME_MIGRATION_FILES.contains(&file.as_ref()) { + return; + } + for name in RETIRED_SQL_NAMES { + for kw in sql_keywords { + if line.contains(&format!("{kw} {name}")) { + offenders.push(format!("{}:{line_no}: {}", path.display(), line.trim())); + } + } + } + }); + } + assert!( + offenders.is_empty(), + "retired table name used in SQL: {offenders:#?}" + ); +} + +fn visit(dir: &Path, on_line: &mut impl FnMut(&Path, usize, &str)) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + visit(&p, on_line); + } else if p.extension().is_some_and(|e| e == "rs") { + let Ok(text) = std::fs::read_to_string(&p) else { + continue; + }; + for (i, line) in text.lines().enumerate() { + on_line(&p, i + 1, line); + } + } + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rs b/packages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rs new file mode 100644 index 00000000000..eaeb56e02f8 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rs @@ -0,0 +1,110 @@ +//! Second-open guard: a process-wide registry refuses a second +//! `SqlitePersister::open()` on the same canonical path while the first +//! is alive, so two in-process handles can't diverge (each owns an +//! independent `Mutex` + write buffer). Dropping the first +//! releases the claim so a later open succeeds. + +mod common; + +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +/// `SqlitePersister` is not `Debug`, so `Result::expect_err` can't be +/// used on an `open()` result — extract the error by matching instead. +fn open_err(cfg: SqlitePersisterConfig) -> WalletStorageError { + match SqlitePersister::open(cfg) { + Ok(_) => panic!("expected open() to fail"), + Err(e) => e, + } +} + +#[test] +fn second_open_on_same_path_is_refused() { + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("w.db"); + + let first = SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("first open"); + + let err = open_err(SqlitePersisterConfig::new(&path)); + assert!( + matches!(err, WalletStorageError::AlreadyOpen { .. }), + "expected AlreadyOpen, got {err:?}" + ); + + // Releasing the first handle frees the claim. + drop(first); + let _reopened = SqlitePersister::open(SqlitePersisterConfig::new(&path)) + .expect("open after the first handle drops must succeed"); +} + +#[test] +fn distinct_paths_open_concurrently() { + let tmp = common::secure_tempdir().unwrap(); + let a = tmp.path().join("a.db"); + let b = tmp.path().join("b.db"); + + let _pa = SqlitePersister::open(SqlitePersisterConfig::new(&a)).expect("open a"); + // A different path is unaffected by the registry. + let _pb = SqlitePersister::open(SqlitePersisterConfig::new(&b)).expect("open b"); +} + +#[test] +fn second_open_via_noncanonical_path_is_refused() { + // A `.`-segmented path canonicalizes to the same key as the plain + // path, so the registry still catches the second open. + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let _first = SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("first open"); + + let dotted = tmp.path().join(".").join("w.db"); + let err = open_err(SqlitePersisterConfig::new(&dotted)); + assert!( + matches!(err, WalletStorageError::AlreadyOpen { .. }), + "expected AlreadyOpen for the equivalent path, got {err:?}" + ); +} + +/// A refused second open must not touch the database file. +/// +/// The registry claim is what makes that true, so it has to be taken before +/// the file is pre-created and before migrations run. Claiming afterwards +/// leaves a window in which two concurrent opens both compute their pending- +/// migration list from the same pre-migration history and both apply it — the +/// second pass rebuilding a table the first already rebuilt, on the one file +/// that is the wallet. +/// +/// `AlreadyOpen` comes back under either ordering, so the error type proves +/// nothing here. The evidence is the side effect: the losing open leaves no +/// file behind. +#[test] +fn refused_second_open_does_not_touch_the_database_file() { + let tmp = common::secure_tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let first = SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("first open"); + + // Unlink the database out from under the live handle. POSIX keeps its open + // descriptors valid, so `first` still holds the claim while the path is + // free — which isolates "did the refused open create and migrate a fresh + // database?" from every other effect an open has. + for suffix in ["", "-wal", "-shm"] { + let mut name = path.clone().into_os_string(); + name.push(suffix); + let _ = std::fs::remove_file(std::path::PathBuf::from(name)); + } + assert!( + !path.exists(), + "fixture is broken: the database file must be gone before the second open" + ); + + let err = open_err(SqlitePersisterConfig::new(&path)); + assert!( + matches!(err, WalletStorageError::AlreadyOpen { .. }), + "expected AlreadyOpen, got {err:?}" + ); + assert!( + !path.exists(), + "the refused open pre-created and migrated a database before checking \ + the registry — the claim is being taken too late" + ); + + drop(first); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_shielded_viewing_keys.rs b/packages/rs-platform-wallet-storage/tests/sqlite_shielded_viewing_keys.rs new file mode 100644 index 00000000000..cd69dd9a2f3 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_shielded_viewing_keys.rs @@ -0,0 +1,108 @@ +#![cfg(feature = "shielded")] + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::changeset::{ + PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + ShieldedChangeSet, +}; +use platform_wallet::wallet::shielded::SubwalletId; +use platform_wallet_storage::WalletStorageError; + +fn changeset(id: SubwalletId, viewing_key: [u8; 96]) -> PlatformWalletChangeSet { + let mut shielded = ShieldedChangeSet::default(); + shielded.record_viewing_key(id, viewing_key); + PlatformWalletChangeSet { + shielded: Some(shielded), + ..Default::default() + } +} + +#[test] +fn shielded_viewing_keys_round_trip_across_wallets_and_upsert() { + let (persister, _tmp, _path) = fresh_persister(); + let wallet_a = wid(0x31); + let wallet_b = wid(0x32); + let subwallet_a = SubwalletId::new(wallet_a, 4); + let subwallet_b = SubwalletId::new(wallet_b, 9); + ensure_wallet_meta(&persister, &wallet_a); + ensure_wallet_meta(&persister, &wallet_b); + + persister + .store(wallet_a, changeset(subwallet_a, [0xA1; 96])) + .expect("store wallet A viewing key"); + persister + .store(wallet_b, changeset(subwallet_b, [0xB2; 96])) + .expect("store wallet B viewing key"); + persister + .store(wallet_a, changeset(subwallet_a, [0xC3; 96])) + .expect("upsert wallet A viewing key"); + + let state = persister.load().expect("load viewing keys"); + assert_eq!(state.shielded.viewing_keys.len(), 2); + assert_eq!( + state.shielded.viewing_keys.get(&subwallet_a), + Some(&vec![0xC3; 96]) + ); + assert_eq!( + state.shielded.viewing_keys.get(&subwallet_b), + Some(&vec![0xB2; 96]) + ); +} + +#[test] +fn shielded_viewing_key_wallet_mismatch_is_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let submitted_wallet = wid(0x41); + let entry_wallet = wid(0x42); + ensure_wallet_meta(&persister, &submitted_wallet); + + let error = persister + .store( + submitted_wallet, + changeset(SubwalletId::new(entry_wallet, 2), [0xD4; 96]), + ) + .expect_err("cross-wallet viewing key must be rejected"); + + let PersistenceError::Backend { source, .. } = error else { + panic!("expected typed backend error"); + }; + assert!(matches!( + source.downcast_ref::(), + Some(WalletStorageError::WalletIdMismatch { expected, found }) + if *expected == submitted_wallet && *found == entry_wallet + )); +} + +#[test] +fn sqlite_advertises_shielded_viewing_key_capability() { + let (persister, _tmp, _path) = fresh_persister(); + assert!(persister + .persistence_capabilities() + .contains(PersistenceCapabilities::SHIELDED_VIEWING_KEYS)); +} + +#[test] +fn delete_wallet_cascades_shielded_viewing_keys() { + let (persister, _tmp, path) = fresh_persister(); + let wallet_id = wid(0x61); + let subwallet_id = SubwalletId::new(wallet_id, 7); + ensure_wallet_meta(&persister, &wallet_id); + persister + .store(wallet_id, changeset(subwallet_id, [0xA7; 96])) + .expect("store viewing key"); + + persister + .delete_wallet(wallet_id) + .expect("inherent deletion must succeed"); + + let remaining: i64 = common::ro_conn(&path) + .query_row( + "SELECT COUNT(*) FROM shielded_viewing_keys WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(remaining, 0); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_store_flush_seam.rs b/packages/rs-platform-wallet-storage/tests/sqlite_store_flush_seam.rs new file mode 100644 index 00000000000..600497e3ddf --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_store_flush_seam.rs @@ -0,0 +1,122 @@ +//! An `Immediate`-mode `store()` reports the fate of its OWN write. +//! +//! `store()` merges into the buffer and then flushes. `flush_inner` is +//! not special to the call that triggered it — an explicit `flush()`, a +//! `commit_writes()`, or another thread's `store()` runs the same code. +//! Whichever one drains the buffer first owns the changesets it took, +//! and a fatal write failure drops them and returns `Err` to THAT +//! caller. If a bystander could drain between this call's merge and its +//! flush, this call would then find an empty buffer and report `Ok(())` +//! for a write that was silently destroyed — contradicting the +//! Immediate-mode durability contract in `store()`'s own rustdoc. +//! +//! The window is closed by lock structure, not by timing: every path +//! that drains the buffer (`flush_inner`, `delete_wallet`) holds the +//! write connection across take + write + restore, and an Immediate +//! `store()` holds that same connection continuously from before its +//! merge until after its own flush returns. `release_at_store_seam` +//! parks the bystander exactly in the window, so a regression fails +//! every run rather than one run in hundreds. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, release_at_store_seam, ro_conn, wid}; + +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::WalletStorageError; + +use std::sync::Arc; +use std::time::Duration; + +/// How long a parked `store()` waits for the bystander's flush. Under +/// the lock discipline it always expires; a regression that lets the +/// drain through finishes in microseconds, far inside the budget. +const BYSTANDER_BUDGET: Duration = Duration::from_secs(1); + +fn changeset(synced_height: u32) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(synced_height), + last_processed_height: Some(synced_height), + ..Default::default() + }), + ..Default::default() + } +} + +fn read_synced_height(path: &std::path::Path, w: &WalletId) -> 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() + .expect("query synced_height") +} + +/// A bystander's flush must not be able to drain — and fatally drop — +/// a changeset the in-flight `store()` merged but has not flushed yet. +/// The caller that merged it is the caller that learns it was dropped. +#[test] +fn a_bystander_flush_cannot_swallow_the_failure_of_an_in_flight_store() { + let (p, _tmp, path) = fresh_persister(); + let p = Arc::new(p); + let w = wid(0x77); + ensure_wallet_meta(&p, &w); + + // Whoever drains the buffer eats this and drops the changeset whole. + // The question this test settles is which caller finds out. + p.force_next_flush_to_fail(WalletStorageError::IntegrityCheckFailed { + report: "simulated fatal".into(), + }); + + let flusher = Arc::clone(&p); + let bystander = release_at_store_seam(&p, BYSTANDER_BUDGET, move || flusher.flush(w).is_ok()); + let stored = p.store(w, changeset(42)); + let bystander_flushed_ok = bystander.join().expect("bystander panicked"); + + assert_eq!( + read_synced_height(&path, &w), + None, + "the injected fatal error means nothing was written" + ); + assert!( + stored.is_err(), + "store() merged the changeset that was fatally dropped, so store() is the \ + call that must report it — got Ok(()) for a write that never reached disk" + ); + assert!( + bystander_flushed_ok, + "the bystander drained nothing, so it has no failure to report" + ); +} + +/// The same choreography with no injected failure — a live guard that +/// holding the connection across merge and flush cannot wedge two +/// threads, and that `Ok(())` still means the row is on disk. +#[test] +fn a_successful_store_still_owns_its_flush_against_a_bystander() { + let (p, _tmp, path) = fresh_persister(); + let p = Arc::new(p); + let w = wid(0x78); + ensure_wallet_meta(&p, &w); + + let flusher = Arc::clone(&p); + let bystander = release_at_store_seam(&p, BYSTANDER_BUDGET, move || flusher.flush(w).is_ok()); + p.store(w, changeset(7)).expect("store"); + assert!( + bystander.join().expect("bystander panicked"), + "a flush that drains nothing succeeds" + ); + + assert_eq!( + read_synced_height(&path, &w), + Some(7), + "store() returned Ok, so the row it merged is durable" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs new file mode 100644 index 00000000000..72039754399 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs @@ -0,0 +1,165 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Store-generation token behaviour: present and stable across a normal +//! flush, and regenerated on restore so a restored copy is distinguishable +//! from its source. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, ro_conn, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::versions; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +/// The generation is present, 16 bytes, and unchanged by a normal +/// changeset flush (it only rotates on migrate/restore). +#[test] +fn generation_present_and_stable_across_flush() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x01); + ensure_wallet_meta(&persister, &w); + + let g1 = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn) + .unwrap() + .expect("fresh V008 store carries a generation") + }; + assert!( + g1.iter().any(|b| *b != 0), + "generation must not be all-zero" + ); + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(10), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let g2 = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + assert_eq!(g1, g2, "a normal flush must not rotate the generation"); + drop(persister); + let _ = path; +} + +/// Restoring from a backup rotates the generation, so a client +/// cache keyed on the pre-restore generation misses rather than serving +/// stale entries. +#[test] +fn generation_rotates_on_restore() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x02); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(5), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let g1 = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + let backup_path = persister.backup_to(tmp.path()).unwrap(); + // The backup is a byte-copy, so it carries the same generation. + { + let bconn = ro_conn(&backup_path); + assert_eq!( + versions::read_generation(&bconn).unwrap().unwrap(), + g1, + "backup carries the source generation verbatim" + ); + } + drop(persister); + + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore"); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let g2 = { + let conn = p2.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + assert_ne!( + g1, g2, + "restore must rotate the generation (restored copy != source)" + ); + drop(p2); + drop(tmp); +} + +/// The generation is rotated as part of the atomic swap — folded into the +/// staged temp BEFORE the rename — not by a post-swap RW re-open on the +/// destination. Proof: right after `restore_from` returns, the destination +/// already carries the rotated token (readable via a read-only open, no RW +/// connection needed) AND has no lingering `-wal`/`-shm` siblings. The old +/// ordering rotated the token through a post-swap RW connection, which on a +/// WAL-mode DB left sibling files behind; folding it into the swap removes any +/// window where restored content is observable with the source's stale token. +#[test] +fn generation_rotated_within_atomic_swap_leaves_no_wal_siblings() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x03); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(9), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let g_src = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + let backup_path = persister.backup_to(tmp.path()).unwrap(); + drop(persister); + + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore"); + + // No WAL/SHM siblings linger: regeneration ran on the staged temp, not via + // a post-swap RW open on the destination. + for ext in ["-wal", "-shm"] { + let sibling = std::path::PathBuf::from(format!("{}{ext}", path.display())); + assert!( + !sibling.exists(), + "restored DB must have no {ext} sibling (regen must not re-open dest RW): {sibling:?}" + ); + } + + // The rotated token is already observable via a read-only open (no RW + // connection created) and differs from the source's. + let g_dst = { + let conn = ro_conn(&path); + versions::read_generation(&conn).unwrap().unwrap() + }; + assert_ne!( + g_src, g_dst, + "restore must rotate the generation within the atomic swap" + ); + drop(tmp); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs index 2da12f8c0b5..e2c438f0649 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -20,14 +20,14 @@ use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet_storage::WalletStorageError; use rusqlite::params; -/// a child insert without a `wallet_metadata` parent is +/// a child insert without a `wallets` 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) \ + "INSERT INTO identities (wallet_id, identity_index, identity_id, entry_blob, tombstoned) \ VALUES (?1, NULL, ?2, X'00', 0)", params![[7u8; 32].as_slice(), [9u8; 32].as_slice()], ); @@ -38,9 +38,11 @@ fn native_fk_rejects_orphan_child() { ); } -/// an `identity_keys` row whose `identities` parent does not -/// exist is rejected by the FK to `identities(identity_id)` (cascade -/// chain `wallet_metadata → identities → identity_keys`). +/// An `identity_keys` row whose `identities` parent does not exist is +/// rejected by the FK to `identities(identity_id)`. The `wallet_id` +/// parent exists (via `ensure_wallet_meta`), so the failure is +/// specifically the missing identity, not the wallet (cascade chain +/// `wallets → identities → identity_keys`). #[test] fn native_fk_rejects_identity_keys_without_identity() { let (persister, _tmp, _path) = fresh_persister(); @@ -49,9 +51,9 @@ fn native_fk_rejects_identity_keys_without_identity() { 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) \ - VALUES (?1, 0, X'00', X'00')", - params![[3u8; 32].as_slice()], + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, X'00', X'00', NULL)", + params![w.as_slice(), [3u8; 32].as_slice()], ); let err = res.unwrap_err().to_string(); assert!( @@ -115,38 +117,22 @@ fn make_utxo(addr: &Address, vout: u32, value: u64) -> Utxo { Utxo::new(outpoint, txout, addr.clone(), 10, false) } -/// UTXOs resolve their real `account_index` from the derived-address -/// map written earlier in the same transaction, instead of a hardcoded -/// 0. +/// UTXOs without matching pool rows resolve to the default account. #[test] -fn multi_account_utxos_bucket_to_real_account() { +fn utxos_without_pool_rows_bucket_under_default_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_a = p2pkh(0x05); + let addr_b = p2pkh(0x09); { 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), - ], + new_utxos: vec![make_utxo(&addr_a, 0, 1000), make_utxo(&addr_b, 1, 2000)], ..Default::default() }; let tx = conn.transaction().unwrap(); @@ -157,49 +143,20 @@ fn multi_account_utxos_bucket_to_real_account() { 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" + by_account.len(), + 1, + "all UTXOs bucket under a single (default) account" ); assert_eq!( - by_account.get(&9).map(|v| v.len()), - Some(1), - "account 9 should hold exactly one UTXO" + by_account.get(&0).map(|v| v.len()), + Some(2), + "both UTXOs are attributed to the default account (index 0)" ); } -/// A NEW unspent UTXO whose address is absent from -/// `core_derived_addresses` cannot resolve an owning account, so the -/// write is refused with the typed `UtxoAddressNotDerived` instead of -/// silently mis-filing live funds under account 0. +/// A spent-only placeholder persists but remains excluded from unspent reads. #[test] -fn unspent_utxo_on_undeclared_address_is_rejected() { - use platform_wallet_storage::sqlite::schema::core_state; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xC8); - ensure_wallet_meta(&persister, &w); - - let addr_unknown = p2pkh(0xEE); - let mut conn = persister.lock_conn_for_test(); - let cs = CoreChangeSet { - new_utxos: vec![make_utxo(&addr_unknown, 0, 3000)], - ..Default::default() - }; - let tx = conn.transaction().unwrap(); - let err = core_state::apply(&tx, &w, &cs) - .expect_err("unspent UTXO on an undeclared address must error"); - assert!( - matches!(err, WalletStorageError::UtxoAddressNotDerived { .. }), - "expected UtxoAddressNotDerived, got {err:?}" - ); -} - -/// A spent-only placeholder UTXO whose address was never derived still -/// persists with the account-0 fallback — spent rows are excluded from -/// the unspent set, so the placeholder index is inert. -#[test] -fn spent_only_utxo_on_undeclared_address_uses_zero_fallback() { +fn spent_only_utxo_on_undeclared_address_is_excluded() { use platform_wallet_storage::sqlite::schema::core_state; let (persister, _tmp, _path) = fresh_persister(); @@ -231,20 +188,20 @@ fn spent_only_utxo_on_undeclared_address_uses_zero_fallback() { /// 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; + use platform_wallet_storage::sqlite::schema::wallets; 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)", + "INSERT INTO wallets (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"); + let err = wallets::fetch(&conn, &w).expect_err("overflow must error"); assert!( matches!(err, WalletStorageError::IntegerOverflow { .. }), "expected IntegerOverflow, got {err:?}" @@ -427,7 +384,7 @@ fn asset_lock_typed_vs_blob_mismatch_rejected() { status: AssetLockStatus::Built, proof: None, }; - let lifecycle_blob = blob::encode(&entry).unwrap(); + let lifecycle_blob = asset_locks::encode_entry_for_test(&entry).unwrap(); let op_bytes = blob::encode_outpoint(&outpoint).unwrap(); { @@ -441,7 +398,8 @@ fn asset_lock_typed_vs_blob_mismatch_rejected() { } let conn = persister.lock_conn_for_test(); - let err = asset_locks::load_state(&conn, &w).expect_err("mismatch must fail"); + let err = asset_locks::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect_err("mismatch must fail"); assert!( matches!(err, WalletStorageError::AssetLockEntryMismatch { .. }), "expected AssetLockEntryMismatch, got {err:?}" diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs b/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs index 2ea34bbdbab..2ec37b19ae7 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs @@ -1,9 +1,6 @@ #![allow(clippy::field_reassign_with_default)] -//! `delete_wallet` and `commit_writes` are inherent `SqlitePersister` -//! methods (not trait methods), returning the storage crate's -//! `DeleteWalletReport` / `CommitReport`. The persister is still usable -//! behind `Arc` for `store`/`flush`/`load`. +//! Trait dispatch for the SQLite persister and default-safe backend methods. mod common; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs index cca73cab946..6a7bbc1361d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -48,10 +48,15 @@ fn make_utxo(addr: &Address, txid: Txid, vout: u32, value: u64) -> Utxo { fn derive_address(conn: &rusqlite::Connection, w: &WalletId, account_index: u32, addr: &Address) { 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(), account_index as i64, addr.to_string()], + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, script, pool_type, address_index, used) \ + VALUES (?1, 'standard_bip44', ?2, ?3, 0, ?4, 0)", + params![ + w.as_slice(), + account_index as i64, + addr.script_pubkey().as_bytes(), + addr.script_pubkey().as_bytes()[3] as i64 + ], ) .unwrap(); } @@ -2546,16 +2551,16 @@ fn apply_heights(conn: &mut rusqlite::Connection, w: &WalletId, height: u32) { tx.commit().unwrap(); } -/// `(spent, height, winner_mined_height)` of a `core_utxos` row, or `None` +/// `(spent, is_sweep_placeholder, winner_mined_height)` of a `core_utxos` row, or `None` /// when absent. fn utxo_row_state( conn: &rusqlite::Connection, w: &WalletId, op: &OutPoint, -) -> Option<(bool, Option, Option)> { +) -> Option<(bool, bool, Option)> { let bytes = blob::encode_outpoint(op).unwrap(); conn.query_row( - "SELECT spent, height, winner_mined_height FROM core_utxos \ + "SELECT spent, is_sweep_placeholder, winner_mined_height FROM core_utxos \ WHERE wallet_id = ?1 AND outpoint = ?2", params![w.as_slice(), &bytes[..]], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), @@ -2623,7 +2628,7 @@ fn a_never_materialised_tombstone_is_collected_at_finality_and_not_before() { assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + Some((true, true, Some(i64::from(WINNER_HEIGHT)))), "sanity: the sweep left a held, never-materialised row stamped with \ the winner's own mined height — not any observation watermark" ); @@ -2727,7 +2732,7 @@ fn a_mempool_context_sweep_preserves_an_unstamped_tombstone() { seed_tombstone(&mut conn, &w, p, loser, winner, None); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, None)), + Some((true, true, None)), "an unmined IS-locked winner must leave a held, unstamped \ placeholder for input #{i}" ); @@ -2782,7 +2787,7 @@ fn a_mempool_context_sweep_still_spend_marks_a_materialised_coin() { seed_tombstone(&mut conn, &w, p, loser, winner, None); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, Some(10), None)), + Some((true, false, None)), "a materialised coin is spend-marked by the IS-locked winner, with \ no stamp — its funding data is real and the collector never sees it" ); @@ -2829,7 +2834,7 @@ fn a_funding_output_arriving_after_a_mempool_sweep_and_restart_lands_spent() { apply_heights(&mut conn, &w, 25_000); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, None)), + Some((true, true, None)), "the unstamped hold survives the restart and every boundary" ); @@ -2852,7 +2857,7 @@ fn a_funding_output_arriving_after_a_mempool_sweep_and_restart_lands_spent() { ); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, Some(10), None)), + Some((true, false, None)), "materialised on the tombstone: real funding height, still spent, \ permanently outside the collector's reach" ); @@ -2932,7 +2937,7 @@ fn a_materialised_claim_is_never_collected() { seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + Some((true, true, Some(i64::from(WINNER_HEIGHT)))), "sanity: held, unmaterialised, stamped with the winner's height" ); @@ -2949,14 +2954,14 @@ fn a_materialised_claim_is_never_collected() { } assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, Some(10), None)), + Some((true, false, None)), "sanity: materialised — real height, stamp cleared, still spent" ); apply_heights(&mut conn, &w, 10_000); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, Some(10), None)), + Some((true, false, None)), "a materialised claim is the wallet's own coin held spent — no \ boundary may ever collect it" ); @@ -3007,7 +3012,7 @@ fn a_release_frees_a_materialised_claim_in_place() { } assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, Some(10), None)), + Some((true, false, None)), "sanity: materialised — real height, stamp cleared, still spent" ); assert!( @@ -3033,7 +3038,7 @@ fn a_release_frees_a_materialised_claim_in_place() { assert_eq!( utxo_row_state(&conn, &w, &p), - Some((false, Some(10), None)), + Some((false, false, None)), "a released materialised claim is freed in place, keeping its funding data" ); assert!( @@ -3078,7 +3083,7 @@ fn a_tombstone_without_a_winner_height_is_never_collected() { apply_heights(&mut conn, &w, 1_000_010); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, None)), + Some((true, true, None)), "no winner height, no proof of finality — the hold outlasts any boundary" ); } @@ -3140,7 +3145,7 @@ fn a_repointed_tombstone_is_restamped_to_the_later_winners_height() { } assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(i64::from(WINNER_HEIGHT + 50)))), + Some((true, true, Some(i64::from(WINNER_HEIGHT + 50)))), "the re-pointed claim is re-stamped to the later winner's mined height" ); } @@ -3199,7 +3204,7 @@ fn a_mempool_repointed_tombstone_keeps_its_block_context_stamp() { } assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + Some((true, true, Some(i64::from(WINNER_HEIGHT)))), "an unmined winner re-points the claim without touching the earlier \ block-context stamp" ); @@ -3235,7 +3240,7 @@ fn an_unstamped_tombstone_restamped_by_a_block_context_sweep_becomes_collectible seed_tombstone(&mut conn, &w, p, first_loser, second_loser, None); assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, None)), + Some((true, true, None)), "sanity: held and unstamped" ); @@ -3266,7 +3271,7 @@ fn an_unstamped_tombstone_restamped_by_a_block_context_sweep_becomes_collectible } assert_eq!( utxo_row_state(&conn, &w, &p), - Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + Some((true, true, Some(i64::from(WINNER_HEIGHT)))), "the block-context re-point stamps the previously unstamped hold" ); @@ -3278,7 +3283,7 @@ fn an_unstamped_tombstone_restamped_by_a_block_context_sweep_becomes_collectible } /// The valve is scoped to the held PLACEHOLDER shape. A materialised coin -/// (`height` set) is the wallet's own: it knows the funding, and any +/// (placeholder flag clear) is the wallet's own: it knows the funding, and any /// network-final spender of a coin it knows is wallet-relevant (BIP158 /// matches the input's prevout script), so its view of `spent` is /// authoritative. When it re-delivers such a coin unspent — the winner @@ -3327,7 +3332,7 @@ fn a_materialised_coin_the_wallet_re_delivers_unspent_is_released_from_its_hold( } assert_eq!( utxo_row_state(&conn, &w, &x), - Some((true, Some(10), None)), + Some((true, false, None)), "sanity: the sweep holds the materialised coin, unstamped" ); @@ -3413,7 +3418,7 @@ fn a_placeholder_stays_held_after_the_trigger_nulls_its_link() { } assert_eq!( utxo_row_state(&conn, &w, &x), - Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + Some((true, true, Some(i64::from(WINNER_HEIGHT)))), "sanity: X is a held, stamped placeholder" ); // W2 sweeps W on Y. W's row goes, the trigger nulls X's link, and W's @@ -3444,7 +3449,7 @@ fn a_placeholder_stays_held_after_the_trigger_nulls_its_link() { assert_eq!(link, None, "sanity: the trigger nulled the link"); assert_eq!( utxo_row_state(&conn, &w, &x), - Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + Some((true, true, Some(i64::from(WINNER_HEIGHT)))), "the hold and its stamp survive the link going" ); @@ -3520,7 +3525,7 @@ fn a_placeholder_with_a_nulled_link_is_still_collected_at_its_stamp() { /// A delivery through `spent_utxos` is still a delivery: the wallet knows /// the coin and knows it spent. Landing on a held placeholder it must -/// materialise the row — real funding data, `height` set, stamp cleared — +/// materialise the row — real funding data, placeholder flag clear, stamp cleared — /// not just mark it, or the collector would later delete the only durable /// record of the spend and a rescan re-delivery would land the coin /// unspent. @@ -3541,7 +3546,7 @@ fn a_spent_delivery_materialises_a_held_placeholder_out_of_the_collectors_reach( seed_tombstone(&mut conn, &w, x, loser, winner, Some(WINNER_HEIGHT)); assert_eq!( utxo_row_state(&conn, &w, &x), - Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + Some((true, true, Some(i64::from(WINNER_HEIGHT)))), "sanity: a stamped placeholder" ); @@ -3556,8 +3561,8 @@ fn a_spent_delivery_materialises_a_held_placeholder_out_of_the_collectors_reach( } assert_eq!( utxo_row_state(&conn, &w, &x), - Some((true, Some(10), None)), - "the spent delivery materialises the row: funding height set, stamp cleared" + Some((true, false, None)), + "the spent delivery materialises the row: placeholder flag clear, stamp cleared" ); apply_heights(&mut conn, &w, WINNER_HEIGHT + 100); @@ -3611,7 +3616,7 @@ fn a_release_naming_an_output_of_a_co_swept_parent_deletes_it_rather_than_freein } assert_eq!( utxo_row_state(&conn, &w, &parent_output), - Some((true, Some(10), None)), + Some((true, false, None)), "sanity: a materialised, spent parent output with no record behind it" ); @@ -3687,9 +3692,14 @@ fn a_record_and_its_sweep_in_one_round_end_with_the_transaction_gone() { tx.commit().unwrap(); assert!( - core_state::get_tx_record(&conn, &w, &loser) - .unwrap() - .is_none(), + core_state::get_tx_record( + &conn, + &w, + &loser, + &platform_wallet_storage::sqlite::load_ctx::LoadCtx::strict() + ) + .unwrap() + .is_none(), "the sweep runs last and removes the record written in the same round" ); assert!( @@ -3697,3 +3707,119 @@ fn a_record_and_its_sweep_in_one_round_end_with_the_transaction_gone() { "and the output the same round delivered goes with it" ); } + +#[test] +fn should_restore_used_addresses_without_decoding_sweep_placeholders() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE1); + ensure_wallet_meta(&persister, &w); + let mut conn = persister.lock_conn_for_test(); + let input = OutPoint::new(Txid::from_byte_array([0x91; 32]), 0); + seed_tombstone( + &mut conn, + &w, + input, + Txid::from_byte_array([0x92; 32]), + Txid::from_byte_array([0x93; 32]), + None, + ); + let addresses = core_state::load_used_addresses(&conn, &w, Network::Testnet).unwrap(); + assert!(addresses.is_empty()); + let (state, _) = core_state::load_state( + &conn, + &w, + Network::Testnet, + &platform_wallet_storage::sqlite::load_ctx::LoadCtx::strict(), + ) + .unwrap(); + assert!(state.new_utxos.is_empty()); + assert!( + row_exists(&conn, &w, &input), + "the held input stays durable" + ); +} + +#[test] +fn should_preserve_mainline_sweep_holds_through_rehydration_migrations() { + use platform_wallet_storage::sqlite::migrations; + + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", true).unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(7)) + .run(&mut conn) + .unwrap(); + let w = wid(0xE2); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![w.as_slice()], + ) + .unwrap(); + let input = OutPoint::new(Txid::from_byte_array([0x94; 32]), 0); + let encoded = blob::encode_outpoint(&input).unwrap(); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, height, account_index, spent, winner_mined_height) + VALUES (?1, ?2, 0, X'', NULL, 0, 1, 400)", + params![w.as_slice(), encoded], + ) + .unwrap(); + migrations::run(&mut conn).unwrap(); + assert_eq!( + utxo_row_state(&conn, &w, &input), + Some((true, true, Some(400))) + ); + assert!(core_state::load_used_addresses(&conn, &w, Network::Testnet) + .unwrap() + .is_empty()); + apply_heights(&mut conn, &w, 399); + assert!(row_exists(&conn, &w, &input)); + apply_heights(&mut conn, &w, 400); + assert!(!row_exists(&conn, &w, &input)); +} + +#[test] +fn should_remove_a_swept_height_only_transaction_and_its_outputs() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE3); + ensure_wallet_meta(&persister, &w); + let loser = Txid::from_byte_array([0x95; 32]); + let utxo = make_utxo(&p2pkh(0x96), loser, 0, 1000); + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + new_utxos: vec![utxo.clone()], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + assert!(row_exists(&conn, &w, &utxo.outpoint)); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser], + superseded_by: Txid::from_byte_array([0x97; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + assert!(!row_exists(&conn, &w, &utxo.outpoint)); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&loser)], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 0); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_unowned_identities.rs b/packages/rs-platform-wallet-storage/tests/sqlite_unowned_identities.rs new file mode 100644 index 00000000000..029ffa472fa --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_unowned_identities.rs @@ -0,0 +1,181 @@ +//! `SqlitePersister::load_unowned_identities`: the dedicated door for +//! identities that belong to no wallet. +//! +//! These identities are deliberately absent from `load()`, which +//! enumerates registered wallets. The tests here pin both halves of that +//! contract — that the accessor returns them fully keyed, and that a +//! round trip does NOT quietly hand them to a wallet. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +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::{ + IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, + PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::identity::IdentityStatus; +use platform_wallet::wallet::platform_wallet::WalletId; + +/// The all-zero scope: the storage spelling of "owned by no wallet". +const UNOWNED: WalletId = [0u8; 32]; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +/// An identity owned by nobody: no `wallet_id`, and no registration +/// index (an index is a position within a wallet, and there is none). +fn unowned_entry(id: Identifier) -> IdentityEntry { + IdentityEntry { + id, + balance: 4_200, + revision: 3, + 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(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } +} + +fn key_entry(id: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: id, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + } +} + +fn unowned_changeset(id: Identifier) -> PlatformWalletChangeSet { + let mut identities = IdentityChangeSet::default(); + identities.identities.insert(id, unowned_entry(id)); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((id, 0), key_entry(id, 0, 0xAB)); + keys.upserts.insert((id, 1), key_entry(id, 1, 0xCD)); + PlatformWalletChangeSet { + identities: Some(identities), + identity_keys: Some(keys), + ..Default::default() + } +} + +/// Write an unowned identity and its keys at the sentinel scope, reopen, +/// and read it back through the accessor: the identity comes back +/// carrying its keys, and it is STILL unowned. The last part is the +/// whole reason this accessor exists — routing these through a wallet's +/// bucket would let the `identities` orphan-promotion upsert claim them +/// on the next flush. +#[test] +fn unowned_identity_round_trips_with_keys_and_stays_unowned() { + let (persister, tmp, path) = fresh_persister(); + let id = Identifier::from([0x7Au8; 32]); + persister.store(UNOWNED, unowned_changeset(id)).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let unowned = p2.load_unowned_identities().expect("accessor reads"); + + let managed = unowned.get(&id).expect("the unowned identity comes back"); + assert_eq!(managed.identity.balance(), 4_200); + assert_eq!( + managed.wallet_id, None, + "an unowned identity must not come back owned — not by a real \ + wallet, and not by the all-zero sentinel either" + ); + + // Keys are folded in, so the accessor is usable on its own: an + // identity returned without its keys would be a trap. + let keys = managed.identity.public_keys(); + assert_eq!(keys.len(), 2, "both persisted keys must be present"); + assert_eq!(keys[&0].data().as_slice(), &[0xAB; 33]); + assert_eq!(keys[&1].data().as_slice(), &[0xCD; 33]); + + // On disk both sides are a genuine SQL NULL, not a zero blob. + let conn = p2.lock_conn_for_test(); + let identity_null: bool = conn + .query_row( + "SELECT wallet_id IS NULL FROM identities WHERE identity_id = ?1", + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert!(identity_null, "identities.wallet_id must still be NULL"); + let keys_null: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys \ + WHERE identity_id = ?1 AND wallet_id IS NOT NULL", + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + keys_null, 0, + "no key may have acquired a wallet scope across the round trip" + ); + drop(conn); + drop(tmp); +} + +/// The documented limitation, pinned: `load()` does not deliver unowned +/// identities. A registered wallet is present so the store is not +/// trivially empty — its own state loads, the unowned identity does not. +#[test] +fn load_does_not_deliver_unowned_identities() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x2B); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x7Bu8; 32]); + persister.store(UNOWNED, unowned_changeset(id)).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load succeeds"); + assert!( + state.wallets.contains_key(&w), + "the registered wallet still loads normally" + ); + for (wallet_id, wallet_state) in &state.wallets { + assert!( + !wallet_state + .identity_manager + .out_of_wallet_identities + .contains_key(&id), + "the unowned identity leaked into wallet {}'s bucket", + hex::encode(wallet_id) + ); + } + + // ...but it is reachable through its own door. + assert!( + p2.load_unowned_identities().unwrap().contains_key(&id), + "the accessor must still find it" + ); + drop(tmp); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs b/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs new file mode 100644 index 00000000000..c99ba523bcf --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs @@ -0,0 +1,149 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `load()` marks a used-then-emptied address as used in the assembled +//! `core_wallet_info` pools (derived from the full `core_utxos` set, spent + +//! unspent) so the rehydrated wallet never hands it out again as a fresh +//! receive address (address reuse). + +mod common; + +use common::{fresh_persister, wid}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use platform_wallet::changeset::{ + AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + WalletMetadataEntry, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +fn reopen(path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") +} + +/// A spent (zero-balance) UTXO's address is still reported in +/// `used_core_addresses`, even though it no longer contributes to balance. +#[test] +fn spent_utxo_address_is_marked_used() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xDD); + + let wallet = Wallet::from_seed_bytes( + [0x42; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 7); + let address = WalletInfoInterface::monitored_addresses(&info) + .into_iter() + .next() + .unwrap(); + + // Register the wallet so it appears in load()'s payload. + let manifest: Vec = wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 7, + }), + account_registrations: manifest, + ..Default::default() + }, + ) + .unwrap(); + + // A UTXO on `address`, then spend it: the row stays on disk with + // spent = 1 and contributes no balance. + let utxo = key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: { + use dashcore::hashes::Hash; + dashcore::Txid::from_byte_array([0x99; 32]) + }, + vout: 0, + }, + txout: dashcore::TxOut { + value: 500_000, + script_pubkey: address.script_pubkey(), + }, + address: address.clone(), + height: 5, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo.clone()], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + spent_utxos: vec![utxo], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load"); + let slice = state.wallets.get(&w).expect("wallet slice"); + + // Spent UTXO: contributes no balance, but its address is still marked used + // in the assembled wallet's pool so it is never handed out as fresh again. + assert_eq!( + slice.wallet_info.balance.total(), + 0, + "the spent UTXO must not contribute balance" + ); + let funds = slice + .wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .expect("funds account present"); + let marked_used = funds + .managed_account_type() + .address_pools() + .iter() + .any(|p| { + p.address_info(&address) + .map(|i| i.is_used()) + .unwrap_or(false) + }); + assert!( + marked_used, + "a spent UTXO's address must still be marked used in the pool" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v008_isolation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v008_isolation.rs new file mode 100644 index 00000000000..c02cb1fd9fc --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v008_isolation.rs @@ -0,0 +1,87 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Cross-wallet isolation + delete cascade for the new V009 tables +//! (`core_address_pool`, `meta_data_versions`). Two wallets with +//! fully-overlapping keys must not collide, must not leak across wallets, and +//! deleting one must leave the other's V009 rows intact. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::wallet::platform_wallet::WalletId; + +fn pool_count(conn: &rusqlite::Connection, w: &WalletId) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap() +} + +fn versions_count(conn: &rusqlite::Connection, w: &WalletId) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM meta_data_versions WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap() +} + +/// Overlapping keys across two wallets coexist without PK +/// collision, and deleting wallet A cascades away only A's V009 rows while +/// wallet B's survive intact. +#[test] +fn v009_tables_isolate_and_cascade_per_wallet() { + let (persister, _tmp, _path) = fresh_persister(); + let a: WalletId = wid(0x0A); + let b: WalletId = wid(0x0B); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + + // Identical (account_type, account_index, key_class, pool_type, + // address_index, domain) for both wallets — only wallet_id differs. + { + let conn = persister.lock_conn_for_test(); + for w in [&a, &b] { + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, 0, ?2, 1)", + rusqlite::params![w.as_slice(), &[0xEEu8; 25][..]], + ) + .expect("overlapping-key pool rows must not collide across wallets"); + conn.execute( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) \ + VALUES (?1, 'core', 3)", + rusqlite::params![w.as_slice()], + ) + .expect("overlapping-domain version rows must not collide across wallets"); + } + + // No cross-wallet read leakage: each wallet sees exactly its own row. + assert_eq!(pool_count(&conn, &a), 1); + assert_eq!(pool_count(&conn, &b), 1); + assert_eq!(versions_count(&conn, &a), 1); + assert_eq!(versions_count(&conn, &b), 1); + } + + // Delete wallet A — FK ON DELETE CASCADE (core_address_pool) and the + // meta_data_versions soft-cascade trigger must reap only A's rows. + persister.delete_wallet_skip_backup(a).expect("delete A"); + + let conn = persister.lock_conn_for_test(); + assert_eq!(pool_count(&conn, &a), 0, "A's pool rows cascade-deleted"); + assert_eq!( + versions_count(&conn, &a), + 0, + "A's version rows removed by the delete trigger" + ); + assert_eq!(pool_count(&conn, &b), 1, "B's pool rows survive A's delete"); + assert_eq!( + versions_count(&conn, &b), + 1, + "B's version rows survive A's delete" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v008_migration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v008_migration.rs new file mode 100644 index 00000000000..0e7f3e79244 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v008_migration.rs @@ -0,0 +1,224 @@ +#![allow(clippy::field_reassign_with_default)] + +//! V009 unified-migration schema tests. The unified migration sequences after +//! the seven migrations `v4.2-dev` already ships (V001-V007), whose version +//! numbers are owned by merged history and must never be reassigned. +//! +//! Covers a fresh store migrating clean to the new target version, the +//! `meta_data_versions` shape and PK, the `core_address_pool` shape and PK, +//! and the store-generation seed. + +mod common; + +use std::collections::BTreeMap; + +use common::fresh_persister; +use platform_wallet_storage::sqlite::migrations as mig; +use rusqlite::Connection; + +/// Column metadata from `PRAGMA table_info`: name → (type, notnull, pk_pos). +fn table_columns(conn: &Connection, table: &str) -> BTreeMap { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .expect("prepare table_info"); + let rows = stmt + .query_map([], |row| { + let name: String = row.get(1)?; + let ty: String = row.get(2)?; + let notnull: i64 = row.get(3)?; + let pk: i64 = row.get(5)?; + Ok((name, (ty, notnull != 0, pk))) + }) + .expect("query table_info"); + rows.map(|r| r.expect("row")).collect() +} + +fn table_exists(conn: &Connection, table: &str) -> bool { + conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + rusqlite::params![table], + |_| Ok(()), + ) + .optional_exists() +} + +trait OptionalExists { + fn optional_exists(self) -> bool; +} +impl OptionalExists for rusqlite::Result<()> { + fn optional_exists(self) -> bool { + matches!(self, Ok(())) + } +} + +/// The unified migration is embedded and supported. The exact ceiling moves +/// with the newest migration and is pinned in that migration's own test file. +#[test] +fn v009_is_embedded_and_supported() { + assert!( + mig::embedded_migrations().iter().any(|(v, _)| *v == 9), + "V009 must be in the embedded migration set" + ); + assert!(mig::max_supported_version() >= 9, "V009 must be applicable"); +} + +/// A fresh store applies V009 and migrates clean through to the +/// newest embedded migration (e.g. V004's DIP-13 invitations table), and +/// every V009 table exists. +#[test] +fn fresh_store_applies_unified_migration() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let applied: i64 = conn + .query_row( + "SELECT COUNT(*) FROM refinery_schema_history WHERE version = 9", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(applied, 1, "a fresh store must apply V009"); + let max: i64 = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + max, + mig::max_supported_version(), + "fresh store must land at the newest embedded schema version" + ); + for table in [ + "core_address_pool", + "meta_data_versions", + "meta_store_generation", + ] { + assert!(table_exists(&conn, table), "missing table {table}"); + } +} + +/// Schema half of the pool coverage: `core_address_pool` carries per-index rows +/// scoped by `(wallet_id, account_type, account_index, key_class, +/// user_identity_id, friend_identity_id, pool_type, address_index)`, a +/// stored `script`, and a `used` flag. The DashPay identity pair is in the PK +/// (mirroring `account_registrations`) so distinct contacts, which otherwise +/// collapse to the same `(dashpay_receiving, 0)` sentinel, never overwrite +/// each other's pool rows (T5). +#[test] +fn core_address_pool_shape() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let cols = table_columns(&conn, "core_address_pool"); + + for (name, ty) in [ + ("wallet_id", "BLOB"), + ("account_type", "TEXT"), + ("account_index", "INTEGER"), + ("key_class", "INTEGER"), + ("user_identity_id", "BLOB"), + ("friend_identity_id", "BLOB"), + ("pool_type", "INTEGER"), + ("address_index", "INTEGER"), + ("script", "BLOB"), + ("used", "INTEGER"), + ] { + let col = cols + .get(name) + .unwrap_or_else(|| panic!("core_address_pool missing column {name}")); + assert_eq!(col.0, ty, "column {name} has unexpected type"); + } + + // Composite PK includes account_type so accounts collapsing to the same + // (account_index, key_class) sentinel never overwrite each other, the + // DashPay identity pair so distinct contacts never overwrite each other, + // and pool_type so External/Internal pools never collide at one + // address_index. + let pk: BTreeMap = cols + .iter() + .filter(|(_, (_, _, pk))| *pk > 0) + .map(|(name, (_, _, pk))| (*pk, name.clone())) + .collect(); + let pk_order: Vec<&str> = pk.values().map(String::as_str).collect(); + assert_eq!( + pk_order, + vec![ + "wallet_id", + "account_type", + "account_index", + "key_class", + "user_identity_id", + "friend_identity_id", + "pool_type", + "address_index" + ], + "core_address_pool PK must be (wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id, pool_type, address_index)" + ); +} + +/// `meta_data_versions` is `(wallet_id BLOB, domain TEXT, seq +/// INTEGER)` with composite PK `(wallet_id, domain)`; `seq` defaults to 0. +#[test] +fn meta_data_versions_shape() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let cols = table_columns(&conn, "meta_data_versions"); + + assert_eq!(cols["wallet_id"].0, "BLOB"); + assert_eq!(cols["domain"].0, "TEXT"); + assert_eq!(cols["seq"].0, "INTEGER"); + + let pk: BTreeMap = cols + .iter() + .filter(|(_, (_, _, pk))| *pk > 0) + .map(|(name, (_, _, pk))| (*pk, name.clone())) + .collect(); + let pk_order: Vec<&str> = pk.values().map(String::as_str).collect(); + assert_eq!( + pk_order, + vec!["wallet_id", "domain"], + "meta_data_versions PK must be (wallet_id, domain)" + ); + + // A domain with no writes yet has seq default 0. + let w = [0x01u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO meta_data_versions (wallet_id, domain) VALUES (?1, 'core_pool')", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + let seq: i64 = conn + .query_row( + "SELECT seq FROM meta_data_versions WHERE wallet_id = ?1 AND domain = 'core_pool'", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(seq, 0, "seq must default to 0 for a fresh domain"); +} + +/// The store-generation token is seeded on migration as a non-empty +/// 16-byte blob in the single-row `meta_store_generation` table. +#[test] +fn store_generation_seeded_16_bytes() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let gen: Vec = conn + .query_row( + "SELECT generation FROM meta_store_generation WHERE id = 0", + [], + |r| r.get(0), + ) + .expect("store generation row must exist"); + assert_eq!(gen.len(), 16, "store generation must be 16 bytes"); + assert!( + gen.iter().any(|b| *b != 0), + "generation must not be all-zero" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs new file mode 100644 index 00000000000..17ad01521aa --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs @@ -0,0 +1,534 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `meta_data_versions` bump discipline: the bump rides the flush tx, rollback +//! is atomic (data and bump are all-or-nothing), every domain maps to its own +//! bump with none silently excluded, and the seq saturates rather than wrapping. + +mod common; + +use std::collections::BTreeMap; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::prelude::Identifier; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::AddressPoolType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Network}; +#[cfg(feature = "shielded")] +use platform_wallet::changeset::ShieldedChangeSet; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, ContactChangeSet, + ContactRequestEntry, CoreChangeSet, IdentityChangeSet, IdentityEntry, IdentityKeyEntry, + IdentityKeysChangeSet, PendingContactCrypto, PendingContactCryptoOp, + PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, + SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, +}; +use platform_wallet::wallet::identity::{ContactRequest, IdentityStatus}; +use platform_wallet::wallet::platform_wallet::WalletId; +#[cfg(feature = "shielded")] +use platform_wallet::wallet::shielded::SubwalletId; +use platform_wallet_storage::sqlite::schema::versions::{self, Domain}; + +fn one_external_info(seed_byte: u8) -> AddressInfo { + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_account_type(), + AccountType::Standard { index: 0, .. } + ) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type == AddressPoolType::External && !pool.addresses.is_empty() { + return pool.addresses.values().next().cloned().unwrap(); + } + } + } + panic!("no external pool"); +} + +fn std_account() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } +} + +fn test_xpub() -> key_wallet::bip32::ExtendedPubKey { + key_wallet::bip32::ExtendedPubKey::decode(&hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC2DF1050E2E8FF49C85C2", + ).unwrap()).unwrap() +} + +/// A BLS operator-key account entry — the provider half of the +/// account-registrations domain. +fn provider_operator_entry() -> ProviderKeyAccountEntry { + let wallet = Wallet::from_seed_bytes( + [0x2A; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: ProviderKeyExtendedPubKey::Bls( + wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS operator account") + .bls_public_key + .clone(), + ), + } +} + +/// A changeset that touches exactly one domain, with minimal non-empty data. +/// DB-validity is irrelevant here — `touched_domains` is a pure function. +fn single_domain_changeset(domain: Domain) -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet::default(); + match domain { + Domain::Core => { + cs.core = Some(CoreChangeSet { + synced_height: Some(1), + ..Default::default() + }) + } + Domain::Identities => { + let mut m = BTreeMap::new(); + let id = Identifier::from([0x01; 32]); + m.insert(id, identity_entry(id)); + cs.identities = Some(IdentityChangeSet { + identities: m, + removed: Default::default(), + }); + } + Domain::IdentityKeys => { + let mut keys = IdentityKeysChangeSet::default(); + let id = Identifier::from([0x02; 32]); + keys.upserts.insert((id, 0), identity_key_entry(id)); + cs.identity_keys = Some(keys); + } + Domain::Contacts => { + let mut sent = BTreeMap::new(); + sent.insert( + SentContactRequestKey { + owner_id: Identifier::from([0x03; 32]), + recipient_id: Identifier::from([0x04; 32]), + }, + contact_request_entry(0x03, 0x04), + ); + cs.contacts = Some(ContactChangeSet { + sent_requests: sent, + ..Default::default() + }); + } + Domain::PlatformAddresses => { + cs.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![PlatformAddressBalanceEntry { + wallet_id: [0; 32], + account_index: 0, + address_index: 0, + address: key_wallet::PlatformP2PKHAddress::new([0x05; 20]), + funds: dash_sdk::platform::address_sync::AddressFunds { + balance: 1, + nonce: 0, + as_of_height: 0, + }, + }], + ..Default::default() + }); + } + Domain::AssetLocks => { + cs.asset_locks = Some(AssetLockChangeSet::default()); + // Empty map is "empty" — seed one entry to mark it touched. + cs.asset_locks = Some(asset_lock_changeset()); + } + Domain::TokenBalances => { + let mut balances = BTreeMap::new(); + balances.insert( + (Identifier::from([0x06; 32]), Identifier::from([0x07; 32])), + 1u64, + ); + cs.token_balances = Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }); + } + Domain::DashpayProfiles => { + let mut m = BTreeMap::new(); + m.insert(Identifier::from([0x08; 32]), None); + cs.dashpay_profiles = Some(m); + } + Domain::DashpayPaymentsOverlay => { + let mut inner = BTreeMap::new(); + inner.insert( + "tx".to_string(), + platform_wallet::wallet::identity::PaymentEntry::new_sent( + Identifier::from([0x0A; 32]), + 1, + None, + ), + ); + let mut m = BTreeMap::new(); + m.insert(Identifier::from([0x09; 32]), inner); + cs.dashpay_payments_overlay = Some(m); + } + Domain::Wallets => { + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }); + } + Domain::AccountRegistrations => { + cs.account_registrations = vec![AccountRegistrationEntry { + account_type: std_account(), + account_xpub: test_xpub(), + }]; + // Provider key-material accounts share this domain — both halves + // land in `account_registrations` rows (dashpay/platform#4113). + cs.provider_key_account_registrations = vec![provider_operator_entry()]; + } + Domain::CoreAddressPool => { + cs.account_address_pools = vec![AccountAddressPoolEntry { + account_type: std_account(), + pool_type: AddressPoolType::External, + addresses: vec![], + }]; + } + Domain::PendingContactCrypto => { + cs.pending_contact_crypto_added = vec![PendingContactCrypto { + owner_identity_id: Identifier::from([0x06; 32]), + contact_id: Identifier::from([0x07; 32]), + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: 0, + }]; + } + Domain::Invitations => { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Txid}; + use platform_wallet::changeset::{ + InvitationChangeSet, InvitationEntry, InvitationStatus, + }; + let op = OutPoint { + txid: Txid::from_byte_array([0x0C; 32]), + vout: 0, + }; + let mut invitations = BTreeMap::new(); + invitations.insert( + op, + InvitationEntry { + out_point: op, + funding_index: 0, + amount_duffs: 1, + expiry_unix: 0, + created_at_secs: 0, + has_inviter: false, + status: InvitationStatus::Created, + }, + ); + cs.invitations = Some(InvitationChangeSet { + invitations, + removed: Default::default(), + }); + } + Domain::DpnsNameStates => { + use platform_wallet::changeset::{ + DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry, + }; + let document_id = Identifier::from([0x0F; 32]); + let mut names = BTreeMap::new(); + names.insert( + document_id, + DpnsNameStateEntry { + document_id, + wallet_identity_id: Identifier::from([0x10; 32]), + label: "Alice".to_string(), + normalized_label: "a11ce".to_string(), + normalized_parent_domain_name: "dash".to_string(), + price: None, + status: DpnsNameSaleStatus::Owned, + created_at_ms: None, + updated_at_ms: None, + transferred_at_ms: None, + last_synced_at_ms: 0, + }, + ); + cs.dpns_name_states = Some(DpnsNameStateChangeSet { + names, + removed: Default::default(), + }); + } + Domain::IdentityScanState => { + use platform_wallet::changeset::IdentityScanStateEntry; + cs.identity_scan_state = Some(IdentityScanStateEntry::incomplete(0, 5, vec![1])); + } + #[cfg(feature = "shielded")] + Domain::ShieldedViewingKeys => { + let mut shielded = ShieldedChangeSet::default(); + shielded.record_viewing_key(SubwalletId::new([0x0D; 32], 3), [0x0E; 96]); + cs.shielded = Some(shielded); + } + } + cs +} + +fn identity_entry(id: Identifier) -> IdentityEntry { + IdentityEntry { + id, + balance: 1, + revision: 1, + identity_index: Some(0), + 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(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + } +} + +fn identity_key_entry(id: Identifier) -> IdentityKeyEntry { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + IdentityKeyEntry { + identity_id: id, + key_id: 0, + 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, + }), + public_key_hash: [3u8; 20], + wallet_id: None, + derivation_indices: None, + } +} + +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: 0, + created_at: 0, + }, + } +} + +fn asset_lock_changeset() -> AssetLockChangeSet { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, Txid}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::AssetLockEntry; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + let op = OutPoint { + txid: Txid::from_byte_array([0x0B; 32]), + vout: 0, + }; + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks.insert( + op, + AssetLockEntry { + out_point: op, + 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: 1, + status: AssetLockStatus::Built, + proof: None, + }, + ); + cs +} + +/// Every domain maps to exactly its own bump; none silently +/// excluded. Each single-field changeset yields exactly its domain, and the +/// union covers `Domain::ALL`. The exhaustive destructure in +/// `touched_domains` makes a newly added field a compile error there. +#[test] +fn every_domain_maps_and_isolates() { + use std::collections::BTreeSet; + assert_eq!( + Domain::ALL.len(), + if cfg!(feature = "shielded") { 17 } else { 16 }, + "every compiled persistence domain must be covered" + ); + let mut covered = BTreeSet::new(); + for domain in Domain::ALL { + let cs = single_domain_changeset(domain); + let touched = versions::touched_domains(&cs); + assert_eq!( + touched, + vec![domain], + "single-field changeset for {domain:?} must touch exactly that domain" + ); + covered.insert(domain.as_str()); + } + let all: BTreeSet<&str> = Domain::ALL.iter().map(|d| d.as_str()).collect(); + assert_eq!(covered, all, "all domains must be reachable"); +} + +/// A flush touching the core-pool domain commits the pool row and +/// its `meta_data_versions.seq` together (same connection, same tx). +#[test] +fn bump_rides_the_flush() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB1); + ensure_wallet_meta(&persister, &w); + + let info = one_external_info(0x11); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: std_account(), + pool_type: AddressPoolType::External, + addresses: vec![info.clone()], + }], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let pool_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert!(pool_rows >= 1, "pool row must be present"); + let seq = versions::read_seq(&conn, &w, Domain::CoreAddressPool).unwrap(); + assert_eq!(seq, 1, "the domain's seq bumped in the same flush"); + // No unrelated domain bumped. + assert_eq!(versions::read_seq(&conn, &w, Domain::Core).unwrap(), 0); +} + +/// A domain bumps once per flush; two flushes → seq 2. +#[test] +fn repeated_flush_increments_seq() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB0); + ensure_wallet_meta(&persister, &w); + for _ in 0..2 { + persister + .store(w, single_domain_changeset(Domain::Wallets)) + .unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert_eq!(versions::read_seq(&conn, &w, Domain::Wallets).unwrap(), 2); +} + +/// Atomicity: a flush that fails partway persists neither the +/// data nor the version bump. A pool write plus a token-balance write whose +/// identity FK is absent must roll the whole tx back. +#[test] +fn partial_failure_rolls_back_data_and_bump() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB2); + ensure_wallet_meta(&persister, &w); + + let info = one_external_info(0x22); + let mut balances = BTreeMap::new(); + // No identities row for this id → token_balances FK violation mid-flush. + balances.insert( + (Identifier::from([0xEE; 32]), Identifier::from([0xEF; 32])), + 1u64, + ); + let result = persister.store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: std_account(), + pool_type: AddressPoolType::External, + addresses: vec![info], + }], + token_balances: Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }), + ..Default::default() + }, + ); + assert!(result.is_err(), "FK violation must fail the flush"); + + let conn = persister.lock_conn_for_test(); + let pool_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + let version_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM meta_data_versions WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(pool_rows, 0, "pool write must roll back with the failed tx"); + assert_eq!(version_rows, 0, "no bump may survive a rolled-back flush"); +} + +/// A seq pre-seeded to i64::MAX saturates on the next bump and +/// never wraps to a lower value (which would look like a cache rollback). +#[test] +fn seq_saturates_at_i64_max() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB4); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) \ + VALUES (?1, 'wallets', 9223372036854775807)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + } + persister + .store(w, single_domain_changeset(Domain::Wallets)) + .unwrap(); + let conn = persister.lock_conn_for_test(); + assert_eq!( + versions::read_seq(&conn, &w, Domain::Wallets).unwrap(), + i64::MAX, + "seq must saturate, never wrap" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs b/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs new file mode 100644 index 00000000000..5f83eefb605 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs @@ -0,0 +1,168 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Wallet-DB identity gates: the `application_id` header magic and the +//! `refinery_schema_history` well-formedness probe. +//! +//! - A foreign refinery-versioned SQLite DB (has `schema_history`, passes +//! `integrity_check`, version within range) but the WRONG +//! `application_id` must be rejected as `NotAWalletDb` — both on +//! `restore_from` (destination untouched) and on `open()`. +//! - A wallet DB whose `refinery_schema_history` carries a malformed +//! `applied_on` / `checksum` must surface a typed +//! `SchemaHistoryMalformed` rather than panicking inside refinery. + +mod common; + +use common::{fresh_persister, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; +use rusqlite::Connection; + +/// `SqlitePersister` is not `Debug`, so `Result::expect_err` can't be +/// used on an `open()` result — extract the error by matching instead. +fn open_err(cfg: SqlitePersisterConfig) -> WalletStorageError { + match SqlitePersister::open(cfg) { + Ok(_) => panic!("expected open() to fail"), + Err(e) => e, + } +} + +/// Build a "foreign" refinery-versioned DB at `path`: it has a +/// `refinery_schema_history` table with a well-formed row and passes +/// `integrity_check`, but carries a DIFFERENT `application_id`, so it is +/// NOT a wallet-storage database. +fn write_foreign_refinery_db(path: &std::path::Path, application_id: i32) { + let conn = Connection::open(path).expect("open foreign db"); + conn.pragma_update(None, "application_id", application_id) + .expect("stamp foreign application_id"); + conn.execute_batch( + "CREATE TABLE refinery_schema_history ( + version INTEGER PRIMARY KEY, + name TEXT, + applied_on TEXT, + checksum TEXT + ); + INSERT INTO refinery_schema_history (version, name, applied_on, checksum) + VALUES (1, 'initial', '2026-01-01T00:00:00+00:00', '12345'); + CREATE TABLE some_foreign_table (x INTEGER);", + ) + .expect("seed foreign schema"); + drop(conn); +} + +/// Materialize a real wallet DB on disk, then return its path inside a +/// kept-alive tempdir. +fn fresh_wallet_db() -> (tempfile::TempDir, std::path::PathBuf) { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x11); + let mut cs = PlatformWalletChangeSet::default(); + cs.wallet_metadata = Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 0, + }); + cs.core = Some(CoreChangeSet { + synced_height: Some(5), + last_processed_height: Some(5), + ..Default::default() + }); + persister.store(w, cs).expect("store"); + persister.flush(w).expect("flush"); + drop(persister); + (tmp, path) +} + +#[test] +fn restore_from_rejects_foreign_application_id_destination_untouched() { + let (_tmp, dest) = fresh_wallet_db(); + + // Snapshot the live destination bytes so we can prove restore left it + // untouched on rejection. + let before = std::fs::read(&dest).expect("read dest before"); + + let src_tmp = common::secure_tempdir().unwrap(); + let foreign = src_tmp.path().join("foreign.db"); + // Anything but the wallet-storage magic. + write_foreign_refinery_db(&foreign, 0x0BAD_F00D_u32 as i32); + + let err = SqlitePersister::restore_from_skip_backup(&dest, &foreign) + .expect_err("restore of a foreign refinery DB must fail"); + assert!( + matches!(err, WalletStorageError::NotAWalletDb { .. }), + "expected NotAWalletDb, got {err:?}" + ); + + let after = std::fs::read(&dest).expect("read dest after"); + assert_eq!( + before, after, + "destination wallet DB must be byte-identical after a rejected restore" + ); + + // The destination must still open as a wallet DB. + SqlitePersister::open(SqlitePersisterConfig::new(&dest)) + .expect("destination still opens after rejected restore"); +} + +#[test] +fn open_rejects_foreign_application_id() { + let tmp = common::secure_tempdir().unwrap(); + let foreign = tmp.path().join("foreign.db"); + write_foreign_refinery_db(&foreign, 0x0BAD_F00D_u32 as i32); + + let err = open_err(SqlitePersisterConfig::new(&foreign)); + assert!( + matches!(err, WalletStorageError::NotAWalletDb { .. }), + "expected NotAWalletDb, got {err:?}" + ); +} + +#[test] +fn open_accepts_a_real_wallet_db_with_stamped_application_id() { + let (_tmp, path) = fresh_wallet_db(); + // Reopening a genuine wallet DB must pass the application_id gate. + SqlitePersister::open(SqlitePersisterConfig::new(&path)) + .expect("reopen of a genuine wallet DB must succeed"); +} + +#[test] +fn open_rejects_malformed_schema_history_without_panicking() { + let (_tmp, path) = fresh_wallet_db(); + + // Corrupt the schema_history `applied_on` to a non-RFC3339 value via + // a side connection, then reopen. Refinery would unwrap()-panic on + // this; the pre-run probe must turn it into a typed error. + { + let conn = Connection::open(&path).expect("side conn"); + conn.execute( + "UPDATE refinery_schema_history SET applied_on = 'not-a-timestamp'", + [], + ) + .expect("corrupt applied_on"); + } + + let err = open_err(SqlitePersisterConfig::new(&path)); + assert!( + matches!(err, WalletStorageError::SchemaHistoryMalformed { .. }), + "expected SchemaHistoryMalformed, got {err:?}" + ); +} + +#[test] +fn open_rejects_non_numeric_checksum_in_schema_history() { + let (_tmp, path) = fresh_wallet_db(); + { + let conn = Connection::open(&path).expect("side conn"); + conn.execute( + "UPDATE refinery_schema_history SET checksum = 'deadbeef'", + [], + ) + .expect("corrupt checksum"); + } + let err = open_err(SqlitePersisterConfig::new(&path)); + assert!( + matches!(err, WalletStorageError::SchemaHistoryMalformed { .. }), + "expected SchemaHistoryMalformed, got {err:?}" + ); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index b95ff429398..f2f1b562330 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1749,12 +1749,9 @@ pub struct ProviderPlatformNodePubKey { /// so they never enter the [`Self::account_xpub`](AccountRegistrationEntry) /// snapshot the ECDSA accounts ride. Carried on /// [`PlatformWalletChangeSet`] as -/// `Vec`; the FFI layer bincode-encodes the -/// [`extended_public_key`](Self::extended_public_key) into the same -/// `AccountSpecFFI.account_xpub_bytes` slot the ECDSA accounts use (the -/// `type_tag` disambiguates the decode) and the restore side rebuilds a -/// watch-only `BLSAccount` / `EdDSAAccount` from it. Append-only merge, -/// same as [`AccountRegistrationEntry`]. +/// `Vec`. Persistence backends use the account type +/// to identify the key curve and rebuild a watch-only `BLSAccount` or +/// `EdDSAAccount`. Append-only merge, same as [`AccountRegistrationEntry`]. #[derive(Debug, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ProviderKeyAccountEntry { @@ -2059,7 +2056,13 @@ pub struct PlatformWalletChangeSet { /// spent marks, sync watermarks, nullifier checkpoints. The /// commitment tree itself is **not** in here — it lives on /// disk in `ClientPersistentCommitmentTree`'s SQLite file. - #[cfg(feature = "shielded")] + /// + /// Present in every feature combination — downstream crates cannot + /// `cfg` on this crate's features, so a conditional field breaks their + /// exhaustive destructures under Cargo feature unification. Without + /// `shielded` the payload is an inert stand-in that stays empty; omitting + /// it from serde preserves the feature-off wire shape. + #[cfg_attr(all(feature = "serde", not(feature = "shielded")), serde(skip))] pub shielded: Option, } @@ -2187,10 +2190,7 @@ impl Merge for PlatformWalletChangeSet { .extend(other.pending_contact_crypto_added); self.pending_contact_crypto_cleared .extend(other.pending_contact_crypto_cleared); - #[cfg(feature = "shielded")] - { - self.shielded.merge(other.shielded); - } + self.shielded.merge(other.shielded); } fn is_empty(&self) -> bool { @@ -2215,14 +2215,7 @@ impl Merge for PlatformWalletChangeSet { && self.account_address_pools.is_empty() && self.pending_contact_crypto_added.is_empty() && self.pending_contact_crypto_cleared.is_empty(); - #[cfg(feature = "shielded")] - { - core_empty && self.shielded.as_ref().is_none_or(|s| s.is_empty()) - } - #[cfg(not(feature = "shielded"))] - { - core_empty - } + core_empty && self.shielded.as_ref().is_none_or(|s| s.is_empty()) } } @@ -2321,6 +2314,35 @@ mod tests { assert!(cs.is_empty()); } + /// The `shielded` slot is a field in every feature combination, so a + /// downstream crate can destructure the changeset exhaustively without + /// being able to `cfg` on *this* crate's features. Naming the field here + /// stops compiling the moment someone re-gates it — far cheaper than the + /// E0027 that re-gating inflicts on downstream destructures. + #[test] + fn shielded_slot_exists_in_every_feature_configuration() { + let mut cs = PlatformWalletChangeSet { + shielded: Default::default(), + ..Default::default() + }; + assert!(cs.is_empty()); + + cs.merge(PlatformWalletChangeSet::default()); + assert!(cs.is_empty()); + } + + #[cfg(all(feature = "serde", not(feature = "shielded")))] + #[test] + fn feature_off_serde_omits_inert_shielded_slot() { + let value = + serde_json::to_value(PlatformWalletChangeSet::default()).expect("changeset serializes"); + + assert!(!value + .as_object() + .expect("changeset serializes as an object") + .contains_key("shielded")); + } + /// Asset-lock merge is last-write-wins EXCEPT for the Consumed /// terminal: when the wallet-event adapter's batched drain folds a /// stale reconstruction/enrichment snapshot after (or before) the diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 850c99b8916..052b00da3a4 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -22,6 +22,8 @@ pub mod platform_address_sync_start_state; pub mod serde_adapters; #[cfg(feature = "shielded")] pub mod shielded_changeset; +#[cfg(not(feature = "shielded"))] +pub mod shielded_changeset_disabled; #[cfg(feature = "shielded")] pub mod shielded_sync_start_state; pub mod traits; @@ -49,6 +51,8 @@ pub use persistence_capabilities::{PersistenceCapabilities, PERSISTENCE_CAPABILI pub use platform_address_sync_start_state::PlatformAddressSyncStartState; #[cfg(feature = "shielded")] pub use shielded_changeset::ShieldedChangeSet; +#[cfg(not(feature = "shielded"))] +pub use shielded_changeset_disabled::ShieldedChangeSet; #[cfg(feature = "shielded")] pub use shielded_sync_start_state::{ShieldedSubwalletStartState, ShieldedSyncStartState}; pub use traits::{ diff --git a/packages/rs-platform-wallet/src/changeset/shielded_changeset_disabled.rs b/packages/rs-platform-wallet/src/changeset/shielded_changeset_disabled.rs new file mode 100644 index 00000000000..d7bf3226c0e --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/shielded_changeset_disabled.rs @@ -0,0 +1,34 @@ +//! Inert stand-in for [`ShieldedChangeSet`] used when the `shielded` +//! feature is off. +//! +//! [`PlatformWalletChangeSet::shielded`] is a field in every feature +//! combination so downstream crates can destructure the changeset +//! exhaustively. They have to: a crate cannot `cfg` on a dependency's +//! feature, so a field that exists only under `platform-wallet/shielded` +//! is a field they can neither name nor omit once Cargo's feature +//! unification turns that feature on behind their back. +//! +//! [`ShieldedChangeSet`]: crate::changeset::ShieldedChangeSet +//! [`PlatformWalletChangeSet::shielded`]: crate::changeset::PlatformWalletChangeSet::shielded + +use crate::changeset::merge::Merge; + +/// Shielded delta that can never carry data — the `shielded` feature is off. +#[derive(Debug, Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ShieldedChangeSet; + +impl ShieldedChangeSet { + /// Always `true`; this stand-in has nowhere to hold a delta. + pub fn is_empty(&self) -> bool { + true + } +} + +impl Merge for ShieldedChangeSet { + fn merge(&mut self, _other: Self) {} + + fn is_empty(&self) -> bool { + true + } +} diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index de88211e8e3..04cfd7f0d03 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -146,8 +146,8 @@ impl PersistenceError { } /// `true` if the error is a `Backend` whose kind is - /// [`PersistenceErrorKind::Transient`]. `LockPoisoned`, `Fatal`, - /// and `Constraint` all read as non-transient. + /// [`PersistenceErrorKind::Transient`]. `LockPoisoned`, `Fatal`, and + /// `Constraint` all read as non-transient. pub fn is_transient(&self) -> bool { matches!( self, @@ -161,7 +161,7 @@ impl PersistenceError { /// Retry-policy classification for the error. /// /// Returns `None` for [`Self::LockPoisoned`] (which is its own - /// trait-level variant) and `Some(kind)` for [`Self::Backend`]. + /// trait-level variant) and the stored kind for [`Self::Backend`]. /// Callers that always need a kind should treat `None` as /// [`PersistenceErrorKind::Fatal`]. pub fn kind(&self) -> Option { @@ -583,12 +583,6 @@ pub trait PlatformWalletPersistence: Send + Sync { ) -> Result, PersistenceError> { Ok(None) } - - // TODO: `list_wallets` and `delete_wallet` are deferred contract - // candidates. They live as inherent methods on the SQLite backend - // today; they may return to this trait once a cross-backend contract - // (consistent error/report semantics across SQLite, file, and FFI - // backends) is agreed. } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index abd89c534ec..f5271ed3152 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -127,8 +127,7 @@ impl PlatformWalletInfo { // mutates its store directly during sync / spend); the // canonical in-memory state lives there and the // changeset is persistence-side only. Drop here. - #[cfg(feature = "shielded")] - shielded: _, + shielded: _, } = cs; // 1. Core wallet state. In the new event-bus model, a diff --git a/packages/rs-platform-wallet/tests/shielded_changeset_traits.rs b/packages/rs-platform-wallet/tests/shielded_changeset_traits.rs new file mode 100644 index 00000000000..b025b944b4c --- /dev/null +++ b/packages/rs-platform-wallet/tests/shielded_changeset_traits.rs @@ -0,0 +1,23 @@ +//! Public changeset traits must remain available with or without `shielded`. + +use platform_wallet::changeset::ShieldedChangeSet; + +static_assertions::assert_not_impl_any!(ShieldedChangeSet: Copy, PartialEq, Eq); + +#[test] +fn should_support_common_traits() { + fn assert_traits() {} + assert_traits::(); +} + +#[cfg(feature = "serde")] +#[test] +fn should_round_trip_empty_shielded_changeset() { + fn assert_serde() {} + assert_serde::(); + + let empty: ShieldedChangeSet = Default::default(); + let encoded = serde_json::to_string(&empty).unwrap(); + let decoded: ShieldedChangeSet = serde_json::from_str(&encoded).unwrap(); + assert!(decoded.is_empty()); +}